Compare commits

...

39 Commits

Author SHA1 Message Date
psychedelicious
b42e34f7a7 chore: release v4.2.9.dev11 2024-09-02 22:07:28 +10:00
psychedelicious
6ac8478e2b feat(ui): tidy stateApi atoms & add docstrings 2024-09-02 18:10:49 +10:00
psychedelicious
ee04a85006 feat(ui): streamline manager -> react transform interface 2024-09-02 17:10:24 +10:00
psychedelicious
d2b521aa50 tidy(ui): remove unused $isProcessingTransform atom 2024-09-02 17:03:54 +10:00
psychedelicious
f434b487ee docs(ui): docstrings for $canvasCache 2024-09-02 16:43:03 +10:00
psychedelicious
3b04f9d596 feat(ui): tweak bookmark verbiage 2024-09-02 16:18:54 +10:00
psychedelicious
e96639bb97 feat(ui): move transformer state to nanostores
This provides some free reactivity for this canvas-manager-managed state.
2024-09-02 16:07:22 +10:00
psychedelicious
694e9f66bb fix(ui): transform should ignore konva filters (e.g. transparency effect) 2024-09-02 12:42:36 +10:00
psychedelicious
56fc0395e2 feat(ui): add fit to bbox as transform helper 2024-09-02 12:36:55 +10:00
psychedelicious
c412af52ae tidy(ui): transformer organisation 2024-09-02 12:36:51 +10:00
psychedelicious
61cec8c482 fix(ui): disable merge visible when 1 or fewer layers of type 2024-09-02 11:56:55 +10:00
psychedelicious
e5924ed72d feat(ui): brush preview opacity at 0.5 when drawing on mask 2024-09-02 11:55:12 +10:00
psychedelicious
84b4bf0a7c chore(ui): lint 2024-09-02 11:49:19 +10:00
psychedelicious
0982dc3ca1 fix(ui): edge cases in quick switch, simpler logic 2024-09-02 11:48:53 +10:00
psychedelicious
5b1bec3989 chore(ui): lint 2024-09-01 22:34:35 +10:00
psychedelicious
b0860c7249 feat(ui): add bookmark for quick switch 2024-09-01 22:34:05 +10:00
psychedelicious
e9df412c70 fix(ui): force dims on scaled bbox when manual scaling + locked aspect ratio
Closes #5590
2024-09-01 21:08:43 +10:00
psychedelicious
84640a0d51 feat(ui): "Control Layers" -> "Layers" 2024-09-01 20:59:44 +10:00
psychedelicious
61b0c49e28 feat(ui): "IP Adapter" -> "Global IP Adapter" 2024-09-01 20:56:24 +10:00
psychedelicious
7df76ae45b tidy(ui): canvas hotkey hooks 2024-09-01 19:19:02 +10:00
psychedelicious
f1d6dcf8d5 feat(ui): add alt+[ and alt+] hotkeys to cycle through layers 2024-09-01 19:16:51 +10:00
psychedelicious
df819c146b feat(ui): add layer quick switch
Q toggles between the last-selected layers.
2024-09-01 17:20:49 +10:00
psychedelicious
a1404b0e5d feat(ui): bbox hotkey is c 2024-09-01 17:20:18 +10:00
psychedelicious
189481286b fix(ui): select nonexistent entity 2024-09-01 17:04:32 +10:00
psychedelicious
0c58d3cfec feat(ui): brush & eraser width ui/ux
Use same pattern as canvas scale & opacity sliders w/ scaled slider values for precision at low values.
2024-09-01 16:52:38 +10:00
psychedelicious
604dab8384 tidy(ui): canvas scale & entity opacity sliders 2024-09-01 16:51:06 +10:00
psychedelicious
0fae29d501 feat(ui): hotkeys for brush/eraser size 2024-09-01 16:02:45 +10:00
psychedelicious
cd9a33453e feat(ui): use default IP adapter when creating IP adapter 2024-09-01 15:41:41 +10:00
psychedelicious
bb2796d9a2 tidy(ui): organise files 2024-09-01 15:28:16 +10:00
psychedelicious
072bd6c373 feat(ui): remove object count from entity title
This was used for troubleshooting only.
2024-09-01 15:18:56 +10:00
psychedelicious
055a912889 tidy(ui): misc cleanup 2024-09-01 12:54:07 +10:00
psychedelicious
a43e44fd85 docs(ui): docstrings for classes (wip) 2024-09-01 12:49:13 +10:00
psychedelicious
a41a2737be feat(ui): revised canvas module base class
Big cleanup. Makes these classes easier to implement, lots of comments and docstrings to clarify how it all works.

- Add default implementations for `destroy`, `repr` and `getLoggingContext`
- Tidy individual module configs
- Update `CanvasManager.buildLogger` to accept a canvas module as the arg
- Add `CanvasManager.buildPath`
2024-09-01 10:29:21 +10:00
psychedelicious
38b545305b feat(ui): split canvas tool previews into modules 2024-08-31 10:29:39 +10:00
psychedelicious
d34335213e fix(ui): reject on dataURLToImageData 2024-08-31 08:52:41 +10:00
psychedelicious
b021e59c15 fix(ui): correctly set last cursor pos to null 2024-08-31 07:47:33 +10:00
psychedelicious
2c5abd44a7 chore: release v4.2.9.dev10 2024-08-30 23:10:59 +10:00
psychedelicious
765d99ac2f feat(ui): remove entity list context menu (again)
stupid events
2024-08-30 23:10:36 +10:00
psychedelicious
ac9a66a628 fix(ui): entity groups not collapsing 2024-08-30 23:10:15 +10:00
80 changed files with 2963 additions and 1906 deletions

View File

@@ -1654,7 +1654,8 @@
"storeNotInitialized": "Store is not initialized"
},
"controlLayers": {
"saveCanvasToGallery": "Save Canvas To Gallery",
"bookmark": "Bookmark for Quick Switch",
"removeBookmark": "Remove Bookmark",
"saveBboxToGallery": "Save Bbox To Gallery",
"savedToGalleryOk": "Saved to Gallery",
"savedToGalleryError": "Error saving to gallery",
@@ -1725,12 +1726,12 @@
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
"controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)",
"rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)",
"ipAdapters_withCount_hidden": "IP Adapters ({{count}} hidden)",
"globalIPAdapters_withCount_hidden": "Global IP Adapters ({{count}} hidden)",
"inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)",
"regionalGuidance_withCount_visible": "Regional Guidance ({{count}})",
"controlLayers_withCount_visible": "Control Layers ({{count}})",
"rasterLayers_withCount_visible": "Raster Layers ({{count}})",
"ipAdapters_withCount_visible": "IP Adapters ({{count}})",
"globalIPAdapters_withCount_visible": "Global IP Adapters ({{count}})",
"inpaintMasks_withCount_visible": "Inpaint Masks ({{count}})",
"globalControlAdapter": "Global $t(controlnet.controlAdapter_one)",
"globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
@@ -1743,8 +1744,8 @@
"clearProcessor": "Clear Processor",
"resetProcessor": "Reset Processor to Defaults",
"noLayersAdded": "No Layers Added",
"layers_one": "Layer",
"layers_other": "Layers",
"layer_one": "Layer",
"layer_other": "Layers",
"objects_zero": "empty",
"objects_one": "{{count}} object",
"objects_other": "{{count}} objects",
@@ -1780,7 +1781,6 @@
"bbox": "Bbox",
"move": "Move",
"view": "View",
"transform": "Transform",
"colorPicker": "Color Picker"
},
"filter": {
@@ -1790,6 +1790,13 @@
"preview": "Preview",
"apply": "Apply",
"cancel": "Cancel"
},
"transform": {
"transform": "Transform",
"fitToBbox": "Fit to Bbox",
"reset": "Reset",
"apply": "Apply",
"cancel": "Cancel"
}
},
"upscaling": {

View File

@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react';
import type { WritableAtom } from 'nanostores';
import { atom } from 'nanostores';
import { useCallback, useState } from 'react';
type UseBoolean = {
isTrue: boolean;
@@ -50,4 +51,24 @@ export const buildUseBoolean = (initialValue: boolean): [() => UseBoolean, Writa
* Hook to manage a boolean state. Use this for a local boolean state.
* @param initialValue Initial value of the boolean
*/
export const useBoolean = (initialValue: boolean) => buildUseBoolean(initialValue)[0]();
export const useBoolean = (initialValue: boolean) => {
const [isTrue, set] = useState(initialValue);
const setTrue = useCallback(() => {
set(true);
}, [set]);
const setFalse = useCallback(() => {
set(false);
}, [set]);
const toggle = useCallback(() => {
set((val) => !val);
}, [set]);
return {
isTrue,
setTrue,
setFalse,
set,
toggle,
};
};

View File

@@ -128,7 +128,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.controlLayers.entities
.filter((controlLayer) => controlLayer.isEnabled)
.forEach((controlLayer, i) => {
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY['control_layer']);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -158,7 +158,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.ipAdapters.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -186,7 +186,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.regions.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -223,7 +223,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.rasterLayers.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;

View File

@@ -46,7 +46,7 @@ export const CanvasAddEntityButtons = memo(() => {
{t('controlLayers.controlLayer')}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.ipAdapter')}
{t('controlLayers.globalIPAdapter')}
</Button>
</ButtonGroup>
</Flex>

View File

@@ -1,6 +1,6 @@
import { Flex } from '@invoke-ai/ui-library';
import type { Meta, StoryObj } from '@storybook/react';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { CanvasEditor } from 'features/controlLayers/components/CanvasEditor';
const meta: Meta<typeof CanvasEditor> = {
title: 'Feature/ControlLayers',

View File

@@ -2,12 +2,12 @@
import { Flex } from '@invoke-ai/ui-library';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
import { ControlLayersToolbar } from 'features/controlLayers/components/ControlLayersToolbar';
import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { Transform } from 'features/controlLayers/components/Transform';
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';
@@ -28,7 +28,7 @@ export const CanvasEditor = memo(() => {
alignItems="center"
justifyContent="center"
>
<ControlLayersToolbar />
<CanvasToolbar />
<StageComponent />
<Flex position="absolute" bottom={8} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>

View File

@@ -1,5 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import {
controlLayerAdded,
inpaintMaskAdded,
@@ -14,6 +15,7 @@ import { PiPlusBold } from 'react-icons/pi';
export const CanvasEntityListMenuItems = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const defaultIPAdapter = useDefaultIPAdapter();
const addInpaintMask = useCallback(() => {
dispatch(inpaintMaskAdded({ isSelected: true }));
}, [dispatch]);
@@ -27,8 +29,9 @@ export const CanvasEntityListMenuItems = memo(() => {
dispatch(controlLayerAdded({ isSelected: true }));
}, [dispatch]);
const addIPAdapter = useCallback(() => {
dispatch(ipaAdded({ isSelected: true }));
}, [dispatch]);
const overrides = { ipAdapter: defaultIPAdapter };
dispatch(ipaAdded({ isSelected: true, overrides }));
}, [defaultIPAdapter, dispatch]);
return (
<>
@@ -45,7 +48,7 @@ export const CanvasEntityListMenuItems = memo(() => {
{t('controlLayers.controlLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.ipAdapter')}
{t('controlLayers.globalIPAdapter')}
</MenuItem>
</>
);

View File

@@ -37,11 +37,11 @@ function formatPct(v: number | string) {
return `${round(Number(v), 2).toLocaleString()}%`;
}
function mapSliderValueToOpacity(value: number) {
function mapSliderValueToRawValue(value: number) {
return value / 100;
}
function mapOpacityToSliderValue(opacity: number) {
function mapRawValueToSliderValue(opacity: number) {
return opacity * 100;
}
@@ -50,14 +50,14 @@ function formatSliderValue(value: number) {
}
const marks = [
mapOpacityToSliderValue(0),
mapOpacityToSliderValue(0.25),
mapOpacityToSliderValue(0.5),
mapOpacityToSliderValue(0.75),
mapOpacityToSliderValue(1),
mapRawValueToSliderValue(0),
mapRawValueToSliderValue(0.25),
mapRawValueToSliderValue(0.5),
mapRawValueToSliderValue(0.75),
mapRawValueToSliderValue(1),
];
const sliderDefaultValue = mapOpacityToSliderValue(1);
const sliderDefaultValue = mapRawValueToSliderValue(1);
const snapCandidates = marks.slice(1, marks.length - 1);
@@ -95,7 +95,7 @@ export const SelectedEntityOpacity = memo(() => {
if (!$shift.get()) {
snappedOpacity = snapToNearest(opacity, snapCandidates, 2);
}
const mappedOpacity = mapSliderValueToOpacity(snappedOpacity);
const mappedOpacity = mapSliderValueToRawValue(snappedOpacity);
dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity }));
},

View File

@@ -1,37 +1,22 @@
import { Box, ContextMenu, Divider, Flex, MenuList } from '@invoke-ai/ui-library';
import { Divider, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons';
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList';
import { EntityListActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListActionBar';
import { CanvasEntityListMenuItems } from 'features/controlLayers/components/CanvasEntityList/EntityListActionBarAddLayerMenuItems';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectHasEntities } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { memo } from 'react';
export const CanvasPanelContent = memo(() => {
const hasEntities = useAppSelector(selectHasEntities);
const renderMenu = useCallback(
() => (
<MenuList>
<CanvasEntityListMenuItems />
</MenuList>
),
[]
);
return (
<CanvasManagerProviderGate>
<Flex flexDir="column" gap={2} w="full" h="full">
<EntityListActionBar />
<Divider py={0} />
<ContextMenu<HTMLDivElement> renderMenu={renderMenu}>
{(ref) => (
<Box ref={ref} w="full" h="full">
{!hasEntities && <CanvasAddEntityButtons />}
{hasEntities && <CanvasEntityList />}
</Box>
)}
</ContextMenu>
{!hasEntities && <CanvasAddEntityButtons />}
{hasEntities && <CanvasEntityList />}
</Flex>
</CanvasManagerProviderGate>
);

View File

@@ -1,39 +0,0 @@
/* eslint-disable i18next/no-literal-string */
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton';
import { CanvasScale } from 'features/controlLayers/components/CanvasScale';
import { SaveToGalleryButton } from 'features/controlLayers/components/SaveToGalleryButton';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useCanvasUndoRedo } from 'features/controlLayers/hooks/useCanvasUndoRedo';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggle } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react';
export const ControlLayersToolbar = memo(() => {
useCanvasUndoRedo();
return (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton />
<ToolChooser />
<Spacer />
<ToolSettings />
<Spacer />
<CanvasScale />
<CanvasResetViewButton />
<Spacer />
<ToolFillColorPicker />
<SaveToGalleryButton />
<CanvasSettingsPopover />
<ViewerToggle />
</Flex>
</CanvasManagerProviderGate>
);
});
ControlLayersToolbar.displayName = 'ControlLayersToolbar';

View File

@@ -7,7 +7,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
@@ -82,7 +82,7 @@ export const StageComponent = memo(() => {
<Flex
position="absolute"
borderRadius="base"
bgImage={TRANSPARENCY_CHECKER_PATTERN}
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
top={0}
right={0}
bottom={0}

View File

@@ -45,7 +45,7 @@ export const StagingAreaToolbar = memo(() => {
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stateApi.$shouldShowStagedImage);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
useScopeOnMount('stagingArea');
@@ -83,8 +83,8 @@ export const StagingAreaToolbar = memo(() => {
}, [dispatch]);
const onToggleShouldShowStagedImage = useCallback(() => {
canvasManager.stateApi.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stateApi.$shouldShowStagedImage, shouldShowStagedImage]);
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
const onSaveStagingImage = useCallback(() => {
if (!selectedImage) {

View File

@@ -20,12 +20,12 @@ export const ToolBboxButton = memo(() => {
return isTransforming || isFiltering || isStaging;
}, [isFiltering, isStaging, isTransforming]);
useHotkeys('q', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
useHotkeys('c', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
return (
<IconButton
aria-label={`${t('controlLayers.tool.bbox')} (Q)`}
tooltip={`${t('controlLayers.tool.bbox')} (Q)`}
aria-label={`${t('controlLayers.tool.bbox')} (C)`}
tooltip={`${t('controlLayers.tool.bbox')} (C)`}
icon={<PiBoundingBoxBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"

View File

@@ -1,9 +1,12 @@
import {
CompositeNumberInput,
CompositeSlider,
FormControl,
FormLabel,
IconButton,
NumberInput,
NumberInputField,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
@@ -11,47 +14,172 @@ import {
} from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { brushWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
import { memo, useCallback } from 'react';
import { clamp } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
const marks = [0, 100, 200, 300];
const formatPx = (v: number | string) => `${v} px`;
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
const formatPx = (v: number | string) => `${v} px`;
function mapSliderValueToRawValue(value: number) {
if (value <= 40) {
// 0 to 40 on the slider -> 1px to 50px
return 1 + (49 * value) / 40;
} else if (value <= 70) {
// 40 to 70 on the slider -> 50px to 200px
return 50 + (150 * (value - 40)) / 30;
} else {
// 70 to 100 on the slider -> 200px to 600px
return 200 + (400 * (value - 70)) / 30;
}
}
function mapRawValueToSliderValue(value: number) {
if (value <= 50) {
// 1px to 50px -> 0 to 40 on the slider
return ((value - 1) * 40) / 49;
} else if (value <= 200) {
// 50px to 200px -> 40 to 70 on the slider
return 40 + ((value - 50) * 30) / 150;
} else {
// 200px to 600px -> 70 to 100 on the slider
return 70 + ((value - 200) * 30) / 400;
}
}
function formatSliderValue(value: number) {
return `${String(mapSliderValueToRawValue(value))} px`;
}
const marks = [
mapRawValueToSliderValue(1),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(200),
mapRawValueToSliderValue(600),
];
const sliderDefaultValue = mapRawValueToSliderValue(50);
export const ToolBrushWidth = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isSelected = useToolIsSelected('brush');
const width = useAppSelector(selectBrushWidth);
const [localValue, setLocalValue] = useState(width);
const onChange = useCallback(
(v: number) => {
dispatch(brushWidthChanged(Math.round(v)));
dispatch(brushWidthChanged(clamp(Math.round(v), 1, 600)));
},
[dispatch]
);
const increment = useCallback(() => {
let newWidth = Math.round(width * 1.15);
if (newWidth === width) {
newWidth += 1;
}
onChange(newWidth);
}, [onChange, width]);
const decrement = useCallback(() => {
let newWidth = Math.round(width * 0.85);
if (newWidth === width) {
newWidth -= 1;
}
onChange(newWidth);
}, [onChange, width]);
const onChangeSlider = useCallback(
(value: number) => {
onChange(mapSliderValueToRawValue(value));
},
[onChange]
);
const onBlur = useCallback(() => {
if (isNaN(Number(localValue))) {
onChange(50);
setLocalValue(50);
} else {
onChange(localValue);
}
}, [localValue, onChange]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
setLocalValue(valueAsNumber);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
}
},
[onBlur]
);
useEffect(() => {
setLocalValue(width);
}, [width]);
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
return (
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
<Popover>
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<PopoverAnchor>
<NumberInput
display="flex"
alignItems="center"
min={1}
max={600}
defaultValue={50}
value={width}
onChange={onChange}
w={24}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
</PopoverBody>
</PopoverContent>
</Popover>
);
});

View File

@@ -4,16 +4,11 @@ import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrus
import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton';
import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { ToolEraserButton } from './ToolEraserButton';
import { ToolViewButton } from './ToolViewButton';
export const ToolChooser: React.FC = () => {
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
return (
<>
<ButtonGroup isAttached>

View File

@@ -1,9 +1,12 @@
import {
CompositeNumberInput,
CompositeSlider,
FormControl,
FormLabel,
IconButton,
NumberInput,
NumberInputField,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
@@ -11,47 +14,172 @@ import {
} from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { eraserWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
import { memo, useCallback } from 'react';
import { clamp } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
const marks = [0, 100, 200, 300];
const formatPx = (v: number | string) => `${v} px`;
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
const formatPx = (v: number | string) => `${v} px`;
function mapSliderValueToRawValue(value: number) {
if (value <= 40) {
// 0 to 40 on the slider -> 1px to 50px
return 1 + (49 * value) / 40;
} else if (value <= 70) {
// 40 to 70 on the slider -> 50px to 200px
return 50 + (150 * (value - 40)) / 30;
} else {
// 70 to 100 on the slider -> 200px to 600px
return 200 + (400 * (value - 70)) / 30;
}
}
function mapRawValueToSliderValue(value: number) {
if (value <= 50) {
// 1px to 50px -> 0 to 40 on the slider
return ((value - 1) * 40) / 49;
} else if (value <= 200) {
// 50px to 200px -> 40 to 70 on the slider
return 40 + ((value - 50) * 30) / 150;
} else {
// 200px to 600px -> 70 to 100 on the slider
return 70 + ((value - 200) * 30) / 400;
}
}
function formatSliderValue(value: number) {
return `${String(mapSliderValueToRawValue(value))} px`;
}
const marks = [
mapRawValueToSliderValue(1),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(200),
mapRawValueToSliderValue(600),
];
const sliderDefaultValue = mapRawValueToSliderValue(50);
export const ToolEraserWidth = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isSelected = useToolIsSelected('eraser');
const width = useAppSelector(selectEraserWidth);
const [localValue, setLocalValue] = useState(width);
const onChange = useCallback(
(v: number) => {
dispatch(eraserWidthChanged(Math.round(v)));
dispatch(eraserWidthChanged(clamp(Math.round(v), 1, 600)));
},
[dispatch]
);
const increment = useCallback(() => {
let newWidth = Math.round(width * 1.15);
if (newWidth === width) {
newWidth += 1;
}
onChange(newWidth);
}, [onChange, width]);
const decrement = useCallback(() => {
let newWidth = Math.round(width * 0.85);
if (newWidth === width) {
newWidth -= 1;
}
onChange(newWidth);
}, [onChange, width]);
const onChangeSlider = useCallback(
(value: number) => {
onChange(mapSliderValueToRawValue(value));
},
[onChange]
);
const onBlur = useCallback(() => {
if (isNaN(Number(localValue))) {
onChange(50);
setLocalValue(50);
} else {
onChange(localValue);
}
}, [localValue, onChange]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
setLocalValue(valueAsNumber);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
}
},
[onBlur]
);
useEffect(() => {
setLocalValue(width);
}, [width]);
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
return (
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
<Popover>
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<PopoverAnchor>
<NumberInput
display="flex"
alignItems="center"
min={1}
max={600}
defaultValue={50}
value={width}
onChange={onChange}
w={24}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
</PopoverBody>
</PopoverContent>
</Popover>
);
});

View File

@@ -6,7 +6,7 @@ import { memo } from 'react';
export const ToolSettings = memo(() => {
const canvasManager = useCanvasManager();
const tool = useStore(canvasManager.stateApi.$tool);
const tool = useStore(canvasManager.tool.$tool);
if (tool === 'brush') {
return <ToolBrushWidth />;
}

View File

@@ -6,14 +6,14 @@ import { useCallback } from 'react';
export const useToolIsSelected = (tool: Tool) => {
const canvasManager = useCanvasManager();
const isSelected = useStore(computed(canvasManager.stateApi.$tool, (t) => t === tool));
const isSelected = useStore(computed(canvasManager.tool.$tool, (t) => t === tool));
return isSelected;
};
export const useSelectTool = (tool: Tool) => {
const canvasManager = useCanvasManager();
const setTool = useCallback(() => {
canvasManager.stateApi.$tool.set(tool);
}, [canvasManager.stateApi.$tool, tool]);
canvasManager.tool.$tool.set(tool);
}, [canvasManager.tool.$tool, tool]);
return setTool;
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable i18next/no-literal-string */
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton';
import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton';
import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys';
import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggle } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react';
export const CanvasToolbar = memo(() => {
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
useCanvasUndoRedoHotkeys();
useCanvasEntityQuickSwitchHotkey();
useNextPrevEntityHotkeys();
return (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton />
<ToolChooser />
<Spacer />
<ToolSettings />
<Spacer />
<CanvasToolbarScale />
<CanvasToolbarResetViewButton />
<Spacer />
<ToolFillColorPicker />
<CanvasToolbarSaveToGalleryButton />
<CanvasSettingsPopover />
<ViewerToggle />
</Flex>
</CanvasManagerProviderGate>
);
});
CanvasToolbar.displayName = 'CanvasToolbar';

View File

@@ -1,4 +1,4 @@
import { $shift, IconButton } from '@invoke-ai/ui-library';
import { $alt, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
@@ -7,7 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
export const CanvasResetViewButton = memo(() => {
export const CanvasToolbarResetViewButton = memo(() => {
const { t } = useTranslation();
const canvasManager = useStore($canvasManager);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
@@ -27,7 +27,7 @@ export const CanvasResetViewButton = memo(() => {
}, [canvasManager]);
const onReset = useCallback(() => {
if ($shift.get()) {
if ($alt.get()) {
resetView();
} else {
resetZoom();
@@ -35,7 +35,7 @@ export const CanvasResetViewButton = memo(() => {
}, [resetView, resetZoom]);
useHotkeys('r', resetView, { enabled: isCanvasActive }, [isCanvasActive]);
useHotkeys('shift+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
useHotkeys('alt+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
return (
<IconButton
@@ -48,4 +48,4 @@ export const CanvasResetViewButton = memo(() => {
);
});
CanvasResetViewButton.displayName = 'CanvasResetViewButton';
CanvasToolbarResetViewButton.displayName = 'CanvasToolbarResetViewButton';

View File

@@ -13,7 +13,7 @@ const log = logger('canvas');
const [useIsSaving] = buildUseBoolean(false);
export const SaveToGalleryButton = memo(() => {
export const CanvasToolbarSaveToGalleryButton = memo(() => {
const { t } = useTranslation();
const shift = useShiftModifier();
const canvasManager = useCanvasManager();
@@ -50,4 +50,4 @@ export const SaveToGalleryButton = memo(() => {
);
});
SaveToGalleryButton.displayName = 'SaveToGalleryButton';
CanvasToolbarSaveToGalleryButton.displayName = 'CanvasToolbarSaveToGalleryButton';

View File

@@ -15,9 +15,8 @@ import {
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/controlLayers/konva/constants';
import { snapToNearest } from 'features/controlLayers/konva/util';
import { clamp, round } from 'lodash-es';
import { round } from 'lodash-es';
import { computed } from 'nanostores';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
@@ -32,7 +31,7 @@ function formatPct(v: number | string) {
return `${round(Number(v), 2).toLocaleString()}%`;
}
function mapSliderValueToScale(value: number) {
function mapSliderValueToRawValue(value: number) {
if (value <= 40) {
// 0 to 40 -> 10% to 100%
return 10 + (90 * value) / 40;
@@ -45,64 +44,58 @@ function mapSliderValueToScale(value: number) {
}
}
function mapScaleToSliderValue(scale: number) {
if (scale <= 100) {
return ((scale - 10) * 40) / 90;
} else if (scale <= 500) {
return 40 + ((scale - 100) * 30) / 400;
function mapRawValueToSliderValue(value: number) {
if (value <= 100) {
return ((value - 10) * 40) / 90;
} else if (value <= 500) {
return 40 + ((value - 100) * 30) / 400;
} else {
return 70 + ((scale - 500) * 30) / 1500;
return 70 + ((value - 500) * 30) / 1500;
}
}
function formatSliderValue(value: number) {
return String(mapSliderValueToScale(value));
return String(mapSliderValueToRawValue(value));
}
const marks = [
mapScaleToSliderValue(10),
mapScaleToSliderValue(50),
mapScaleToSliderValue(100),
mapScaleToSliderValue(500),
mapScaleToSliderValue(2000),
mapRawValueToSliderValue(10),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(100),
mapRawValueToSliderValue(500),
mapRawValueToSliderValue(2000),
];
const sliderDefaultValue = mapScaleToSliderValue(100);
const sliderDefaultValue = mapRawValueToSliderValue(100);
const snapCandidates = marks.slice(1, marks.length - 1);
export const CanvasScale = memo(() => {
export const CanvasToolbarScale = memo(() => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const scale = useStore(computed(canvasManager.stateApi.$stageAttrs, (attrs) => attrs.scale));
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
const [localScale, setLocalScale] = useState(scale * 100);
const onChangeSlider = useCallback(
(scale: number) => {
if (!canvasManager) {
return;
}
let snappedScale = scale;
// Do not snap if shift key is held
if (!$shift.get()) {
snappedScale = snapToNearest(scale, snapCandidates, 2);
}
const mappedScale = mapSliderValueToScale(snappedScale);
const mappedScale = mapSliderValueToRawValue(snappedScale);
canvasManager.stage.setScale(mappedScale / 100);
},
[canvasManager]
);
const onBlur = useCallback(() => {
if (!canvasManager) {
return;
}
if (isNaN(Number(localScale))) {
canvasManager.stage.setScale(1);
setLocalScale(100);
return;
}
canvasManager.stage.setScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
canvasManager.stage.setScale(localScale / 100);
}, [canvasManager, localScale]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
@@ -130,8 +123,8 @@ export const CanvasScale = memo(() => {
<NumberInput
display="flex"
alignItems="center"
min={MIN_CANVAS_SCALE * 100}
max={MAX_CANVAS_SCALE * 100}
min={canvasManager.stage.config.MIN_SCALE * 100}
max={canvasManager.stage.config.MAX_SCALE * 100}
value={localScale}
onChange={onChangeNumberInput}
onBlur={onBlur}
@@ -162,7 +155,7 @@ export const CanvasScale = memo(() => {
<CompositeSlider
min={0}
max={100}
value={mapScaleToSliderValue(localScale)}
value={mapRawValueToSliderValue(localScale)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
@@ -175,4 +168,4 @@ export const CanvasScale = memo(() => {
);
});
CanvasScale.displayName = 'CanvasScale';
CanvasToolbarScale.displayName = 'CanvasToolbarScale';

View File

@@ -1,19 +1,14 @@
import { Button, ButtonGroup, Flex, Heading, Spacer } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
EntityIdentifierContext,
useEntityIdentifierContext,
} from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsCounterClockwiseBold, PiCheckBold, PiXBold } from 'react-icons/pi';
import { PiArrowsCounterClockwiseBold, PiArrowsOutBold, PiCheckBold, PiXBold } from 'react-icons/pi';
const TransformBox = memo(() => {
const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter }) => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapter(entityIdentifier);
const isProcessing = useStore(adapter.transformer.$isProcessing);
return (
@@ -30,9 +25,19 @@ const TransformBox = memo(() => {
transitionDuration="normal"
>
<Heading size="md" color="base.300" userSelect="none">
{t('controlLayers.tool.transform')}
{t('controlLayers.transform.transform')}
</Heading>
<ButtonGroup isAttached={false} size="sm" w="full">
<Button
leftIcon={<PiArrowsOutBold />}
onClick={adapter.transformer.fitProxyRectToBbox}
isLoading={isProcessing}
loadingText={t('controlLayers.transform.reset')}
variant="ghost"
>
{t('controlLayers.transform.fitToBbox')}
</Button>
<Spacer />
<Button
leftIcon={<PiArrowsCounterClockwiseBold />}
onClick={adapter.transformer.resetTransform}
@@ -40,9 +45,8 @@ const TransformBox = memo(() => {
loadingText={t('controlLayers.reset')}
variant="ghost"
>
{t('accessibility.reset')}
{t('controlLayers.transform.reset')}
</Button>
<Spacer />
<Button
leftIcon={<PiCheckBold />}
onClick={adapter.transformer.applyTransform}
@@ -50,7 +54,7 @@ const TransformBox = memo(() => {
loadingText={t('common.apply')}
variant="ghost"
>
{t('common.apply')}
{t('controlLayers.transform.apply')}
</Button>
<Button
leftIcon={<PiXBold />}
@@ -59,7 +63,7 @@ const TransformBox = memo(() => {
loadingText={t('common.cancel')}
variant="ghost"
>
{t('common.cancel')}
{t('controlLayers.transform.cancel')}
</Button>
</ButtonGroup>
</Flex>
@@ -70,15 +74,11 @@ TransformBox.displayName = 'Transform';
export const Transform = () => {
const canvasManager = useCanvasManager();
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
const adapter = useStore(canvasManager.stateApi.$transformingAdapter);
if (!transformingEntity) {
if (!adapter) {
return null;
}
return (
<EntityIdentifierContext.Provider value={transformingEntity}>
<TransformBox />
</EntityIdentifierContext.Provider>
);
return <TransformBox adapter={adapter} />;
};

View File

@@ -56,7 +56,7 @@ export const CanvasEntityHeader = memo(({ children, ...rest }: FlexProps) => {
}, [entityIdentifier]);
return (
<ContextMenu renderMenu={renderMenu} stopImmediatePropagation>
<ContextMenu renderMenu={renderMenu}>
{(ref) => (
<Flex ref={ref} gap={2} alignItems="center" p={2} {...rest}>
{children}

View File

@@ -1,6 +1,7 @@
import { Flex } from '@invoke-ai/ui-library';
import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityIsBookmarkedForQuickSwitchToggle } from 'features/controlLayers/components/common/CanvasEntityIsBookmarkedForQuickSwitchToggle';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { memo } from 'react';
@@ -10,6 +11,7 @@ export const CanvasEntityHeaderCommonActions = memo(() => {
return (
<Flex alignSelf="stretch">
<CanvasEntityIsBookmarkedForQuickSwitchToggle />
{entityIdentifier.type !== 'ip_adapter' && <CanvasEntityIsLockedToggle />}
<CanvasEntityEnabledToggle />
<CanvasEntityDeleteButton />

View File

@@ -0,0 +1,36 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsBookmarkedForQuickSwitch } from 'features/controlLayers/hooks/useEntityIsBookmarkedForQuickSwitch';
import { bookmarkedEntityChanged } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBookmarkSimpleBold, PiBookmarkSimpleFill } from 'react-icons/pi';
export const CanvasEntityIsBookmarkedForQuickSwitchToggle = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const isBookmarked = useEntityIsBookmarkedForQuickSwitch(entityIdentifier);
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
if (isBookmarked) {
dispatch(bookmarkedEntityChanged({ entityIdentifier: null }));
} else {
dispatch(bookmarkedEntityChanged({ entityIdentifier }));
}
}, [dispatch, entityIdentifier, isBookmarked]);
return (
<IconButton
size="sm"
aria-label={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
tooltip={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
variant="link"
alignSelf="stretch"
icon={isBookmarked ? <PiBookmarkSimpleFill /> : <PiBookmarkSimpleBold />}
onClick={onClick}
/>
);
});
CanvasEntityIsBookmarkedForQuickSwitchToggle.displayName = 'CanvasEntityIsBookmarkedForQuickSwitchToggle';

View File

@@ -12,15 +12,15 @@ export const CanvasEntityMenuItemsTransform = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
const canvasManager = useCanvasManager();
const adapter = useEntityAdapter(entityIdentifier);
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
const isTransforming = useStore(canvasManager.stateApi.$isTranforming);
const onClick = useCallback(() => {
adapter.transformer.startTransform();
}, [adapter.transformer]);
return (
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={Boolean(transformingEntity)}>
{t('controlLayers.tool.transform')}
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={isTransforming}>
{t('controlLayers.transform.transform')}
</MenuItem>
);
});

View File

@@ -3,6 +3,7 @@ import { logger } from 'app/logging/logger';
import { useAppDispatch } from 'app/store/storeHooks';
import { isOk, withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityTypeCount } from 'features/controlLayers/hooks/useEntityTypeCount';
import { inpaintMaskAdded, rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/types';
@@ -22,6 +23,7 @@ export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const entityCount = useEntityTypeCount(type);
const onClick = useCallback(async () => {
if (type === 'raster_layer') {
const rect = canvasManager.stage.getVisibleRect('raster_layer');
@@ -81,6 +83,7 @@ export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => {
icon={<PiStackBold />}
onClick={onClick}
alignSelf="stretch"
isDisabled={entityCount <= 1}
/>
);
});

View File

@@ -4,7 +4,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { useEntityAdapter } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useSelector } from 'react-redux';
@@ -69,7 +69,7 @@ export const CanvasEntityPreviewImage = memo(() => {
ctx.globalCompositeOperation = 'source-in';
ctx.fillRect(0, 0, rect.width, rect.height);
}
}, [adapter.transformer, adapter.transformer.nodeRect, adapter.transformer.pixelRect, cache, maskColor]);
}, [cache, maskColor]);
return (
<Flex
@@ -88,7 +88,7 @@ export const CanvasEntityPreviewImage = memo(() => {
right={0}
bottom={0}
left={0}
bgImage={TRANSPARENCY_CHECKER_PATTERN}
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
bgSize="5px"
opacity={0.1}
/>

View File

@@ -0,0 +1,47 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import {
selectBookmarkedEntityIdentifier,
selectSelectedEntityIdentifier,
} from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
export const useCanvasEntityQuickSwitchHotkey = () => {
const dispatch = useAppDispatch();
const [prev, setPrev] = useState<CanvasEntityIdentifier | null>(null);
const [current, setCurrent] = useState<CanvasEntityIdentifier | null>(null);
const selected = useAppSelector(selectSelectedEntityIdentifier);
const bookmarked = useAppSelector(selectBookmarkedEntityIdentifier);
// Update prev and current when selected entity changes
useEffect(() => {
if (current?.id !== selected?.id) {
setPrev(current);
setCurrent(selected);
}
}, [current, selected]);
const onQuickSwitch = useCallback(() => {
if (bookmarked) {
if (current?.id !== bookmarked.id) {
// Switch between current (non-bookmarked) and bookmarked
setPrev(current);
setCurrent(bookmarked);
dispatch(entitySelected({ entityIdentifier: bookmarked }));
} else if (prev) {
// Switch back to the last non-bookmarked entity
setCurrent(prev);
dispatch(entitySelected({ entityIdentifier: prev }));
}
} else if (prev !== null && current !== null) {
// Switch between prev and current if no bookmarked entity
setPrev(current);
setCurrent(prev);
dispatch(entitySelected({ entityIdentifier: prev }));
}
}, [bookmarked, current, dispatch, prev]);
useHotkeys('q', onQuickSwitch);
};

View File

@@ -1,4 +1,3 @@
/* eslint-disable i18next/no-literal-string */
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
@@ -7,7 +6,7 @@ import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDispatch } from 'react-redux';
export const useCanvasUndoRedo = () => {
export const useCanvasUndoRedoHotkeys = () => {
useAssertSingleton('useCanvasUndoRedo');
const dispatch = useDispatch();

View File

@@ -0,0 +1,18 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityIsBookmarkedForQuickSwitch = (entityIdentifier: CanvasEntityIdentifier) => {
const selectIsBookmarkedForQuickSwitch = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
return canvas.bookmarkedEntityIdentifier?.id === entityIdentifier.id;
}),
[entityIdentifier]
);
const isBookmarkedForQuickSwitch = useAppSelector(selectIsBookmarkedForQuickSwitch);
return isBookmarkedForQuickSwitch;
};

View File

@@ -1,24 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import { type CanvasEntityIdentifier, isDrawableEntity } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityObjectCount = (entityIdentifier: CanvasEntityIdentifier) => {
const selectObjectCount = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntity(canvas, entityIdentifier);
if (!entity) {
return 0;
} else if (isDrawableEntity(entity)) {
return entity.objects.length;
} else {
return 0;
}
}),
[entityIdentifier]
);
const objectCount = useAppSelector(selectObjectCount);
return objectCount;
};

View File

@@ -1,6 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useEntityObjectCount } from 'features/controlLayers/hooks/useEntityObjectCount';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
@@ -20,34 +19,27 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
const { t } = useTranslation();
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
const name = useAppSelector(selectName);
const objectCount = useEntityObjectCount(entityIdentifier);
const title = useMemo(() => {
if (name) {
return name;
}
const parts: string[] = [];
if (entityIdentifier.type === 'inpaint_mask') {
parts.push(t('controlLayers.inpaintMask'));
} else if (entityIdentifier.type === 'control_layer') {
parts.push(t('controlLayers.controlLayer'));
} else if (entityIdentifier.type === 'raster_layer') {
parts.push(t('controlLayers.rasterLayer'));
} else if (entityIdentifier.type === 'ip_adapter') {
parts.push(t('common.ipAdapter'));
} else if (entityIdentifier.type === 'regional_guidance') {
parts.push(t('controlLayers.regionalGuidance'));
} else {
assert(false, 'Unexpected entity type');
switch (entityIdentifier.type) {
case 'inpaint_mask':
return t('controlLayers.inpaintMask');
case 'control_layer':
return t('controlLayers.controlLayer');
case 'raster_layer':
return t('controlLayers.rasterLayer');
case 'ip_adapter':
return t('controlLayers.globalIPAdapter');
case 'regional_guidance':
return t('controlLayers.regionalGuidance');
default:
assert(false, 'Unexpected entity type');
}
if (objectCount > 0) {
parts.push(`(${objectCount})`);
}
return parts.join(' ');
}, [entityIdentifier.type, name, objectCount, t]);
}, [entityIdentifier.type, name, t]);
return title;
};

View File

@@ -16,7 +16,7 @@ export const useEntityTypeString = (type: CanvasEntityIdentifier['type']): strin
case 'regional_guidance':
return t('controlLayers.regionalGuidance');
case 'ip_adapter':
return t('controlLayers.ipAdapter');
return t('controlLayers.globalIPAdapter');
default:
return '';
}

View File

@@ -22,7 +22,7 @@ export const useEntityTypeTitle = (type: CanvasEntityIdentifier['type']): string
case 'regional_guidance':
return t('controlLayers.regionalGuidance_withCount', { count, context });
case 'ip_adapter':
return t('controlLayers.ipAdapters_withCount', { count, context });
return t('controlLayers.globalIPAdapters_withCount', { count, context });
default:
return '';
}

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
export const useIsTransforming = () => {
const canvasManager = useCanvasManager();
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
const transformingEntity = useStore(canvasManager.stateApi.$transformingAdapter);
const isTransforming = useMemo(() => {
return Boolean(transformingEntity);
}, [transformingEntity]);

View File

@@ -48,7 +48,6 @@ export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig
return defaultControlAdapter;
};
/** @knipignore */
export const useDefaultIPAdapter = (): IPAdapterConfig => {
const [modelConfigs] = useIPAdapterModels();

View File

@@ -0,0 +1,76 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import { selectAllEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasEntityState } from 'features/controlLayers/store/types';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const selectNextEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
const allEntities = selectAllEntities(canvas);
let nextEntity: CanvasEntityState | null = null;
if (!selectedEntityIdentifier) {
nextEntity = allEntities[0] ?? null;
} else {
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
nextEntity = allEntities[(selectedEntityIndex + 1) % allEntities.length] ?? null;
}
if (!nextEntity) {
return null;
}
return getEntityIdentifier(nextEntity);
});
const selectPrevEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
const allEntities = selectAllEntities(canvas);
let prevEntity: CanvasEntityState | null = null;
if (!selectedEntityIdentifier) {
prevEntity = allEntities[0] ?? null;
} else {
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
prevEntity = allEntities[(selectedEntityIndex - 1 + allEntities.length) % allEntities.length] ?? null;
}
if (!prevEntity) {
return null;
}
return getEntityIdentifier(prevEntity);
});
export const useNextPrevEntityHotkeys = () => {
useAssertSingleton('useNextPrevEntityHotkeys');
const dispatch = useAppDispatch();
const nextEntityIdentifier = useAppSelector(selectNextEntityIdentifier);
const prevEntityIdentifier = useAppSelector(selectPrevEntityIdentifier);
const selectNextEntity = useCallback(() => {
if (nextEntityIdentifier) {
dispatch(entitySelected({ entityIdentifier: nextEntityIdentifier }));
}
}, [dispatch, nextEntityIdentifier]);
const selectPrevEntity = useCallback(() => {
if (prevEntityIdentifier) {
dispatch(entitySelected({ entityIdentifier: prevEntityIdentifier }));
}
}, [dispatch, prevEntityIdentifier]);
useHotkeys(
// “ === alt+[
['alt+[', '“'],
selectPrevEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectPrevEntity]
);
useHotkeys(
// === alt+]
['alt+]', ''],
selectNextEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectNextEntity]
);
};

View File

@@ -1,45 +1,72 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasBackgroundModule extends CanvasModuleABC {
readonly type = 'background';
type CanvasBackgroundModuleConfig = {
GRID_LINE_COLOR_COARSE: string;
GRID_LINE_COLOR_FINE: string;
};
static GRID_LINE_COLOR_COARSE = getArbitraryBaseColor(27);
static GRID_LINE_COLOR_FINE = getArbitraryBaseColor(18);
const DEFAULT_CONFIG: CanvasBackgroundModuleConfig = {
GRID_LINE_COLOR_COARSE: getArbitraryBaseColor(27),
GRID_LINE_COLOR_FINE: getArbitraryBaseColor(18),
};
/**
* Renders a background grid on the canvas, where the grid spacing changes based on the stage scale.
*
* The grid is only visible when the dynamic grid setting is enabled.
*/
export class CanvasBackgroundModule extends CanvasModuleBase {
readonly type = 'background';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
log: Logger;
subscriptions = new Set<() => void>();
config: CanvasBackgroundModuleConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the background grid:
* - A layer to hold the grid lines
* - An array of grid lines
*/
konva: {
layer: Konva.Layer;
lines: Konva.Line[];
};
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating background module');
this.log.debug('Creating module');
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }) };
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }), lines: [] };
this.subscriptions.add(
this.manager.stateApi.$stageAttrs.listen(() => {
this.render();
})
);
/**
* The background grid should be rendered when the stage attributes change:
* - scale
* - position
* - size
*/
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
}
render() {
/**
* Renders the background grid.
*/
render = () => {
const settings = this.manager.stateApi.getSettings();
if (!settings.dynamicGrid) {
@@ -49,11 +76,10 @@ export class CanvasBackgroundModule extends CanvasModuleABC {
this.konva.layer.visible(true);
this.konva.layer.zIndex(0);
const scale = this.manager.stage.getScale();
const { x, y } = this.manager.stage.getPosition();
const { width, height } = this.manager.stage.getSize();
const gridSpacing = this.getGridSpacing(scale);
const gridSpacing = CanvasBackgroundModule.getGridSpacing(scale);
const stageRect = {
x1: 0,
y1: 0,
@@ -92,47 +118,44 @@ export class CanvasBackgroundModule extends CanvasModuleABC {
let _y = 0;
this.konva.layer.destroyChildren();
this.konva.lines = [];
for (let i = 0; i < xSteps; i++) {
_x = gridFullRect.x1 + i * gridSpacing;
this.konva.layer.add(
new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
const line = new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
});
this.konva.lines.push(line);
this.konva.layer.add(line);
}
for (let i = 0; i < ySteps; i++) {
_y = gridFullRect.y1 + i * gridSpacing;
this.konva.layer.add(
new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
const line = new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
});
this.konva.lines.push(line);
this.konva.layer.add(line);
}
}
destroy = () => {
this.log.trace('Destroying background module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.layer.destroy();
};
/**
* Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller.
* Gets the grid line spacing for the dynamic grid.
*
* The value depends on the stage scale - at higher scales, the grid spacing is smaller.
*
* @param scale The stage scale
* @returns The grid spacing based on the stage scale
*/
getGridSpacing = (scale: number): number => {
static getGridSpacing = (scale: number): number => {
if (scale >= 2) {
return 8;
}
@@ -151,15 +174,9 @@ export class CanvasBackgroundModule extends CanvasModuleABC {
return 256;
};
repr = () => {
return {
id: this.id,
path: this.path,
type: this.type,
};
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
destroy = () => {
this.log.trace('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.layer.destroy();
};
}

View File

@@ -1,10 +1,8 @@
import type { SerializableObject } from 'common/types';
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { Rect } from 'features/controlLayers/store/types';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@@ -23,43 +21,57 @@ const ALL_ANCHORS: string[] = [
const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const NO_ANCHORS: string[] = [];
export class CanvasBboxModule extends CanvasModuleABC {
/**
* Renders the bounding box. The bounding box can be transformed by the user.
*/
export class CanvasBboxModule extends CanvasModuleBase {
readonly type = 'bbox';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions: Set<() => void> = new Set();
parent: CanvasPreviewModule;
/**
* The Konva objects that make up the bbox:
* - A group to hold all the objects
* - A transformer to allow the bbox to be transformed
* - A transparent rect so the transformer has something to transform
*/
konva: {
group: Konva.Group;
rect: Konva.Rect;
transformer: Konva.Transformer;
proxyRect: Konva.Rect;
};
constructor(parent: CanvasPreviewModule) {
/**
* Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is
* used to lock the aspect ratio.
*/
$aspectRatioBuffer = atom(0);
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating bbox module');
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
// transforming the bbox.
// Set the initial aspect ratio buffer per app state.
const bbox = this.manager.stateApi.getBbox();
const $aspectRatioBuffer = atom(bbox.rect.width / bbox.rect.height);
this.$aspectRatioBuffer.set(bbox.rect.width / bbox.rect.height);
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
// transparent rect for this purpose.
rect: new Konva.Rect({
group: new Konva.Group({ name: `${this.type}:group`, listening: true }),
// We will use a Konva.Transformer for the generation bbox. Transformers need some shape to transform, so we will
// create a transparent rect for this purpose.
proxyRect: new Konva.Rect({
name: `${this.type}:rect`,
listening: false,
strokeEnabled: false,
@@ -85,171 +97,43 @@ export class CanvasBboxModule extends CanvasModuleABC {
anchorCornerRadius: 3,
shiftBehavior: 'none', // we will implement our own shift behavior
centeredScaling: false,
anchorStyleFunc: (anchor) => {
// Make the x/y resize anchors little bars
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
anchor.height(8);
anchor.offsetY(4);
anchor.width(30);
anchor.offsetX(15);
}
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
anchor.height(30);
anchor.offsetY(15);
anchor.width(8);
anchor.offsetX(4);
}
},
anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => {
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
// to konva's internal coordinate system.
const stage = this.konva.transformer.getStage();
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
const stageAbsPos = stage.getAbsolutePosition();
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
const offsetX = stageAbsPos.x % scaledGridSize;
const offsetY = stageAbsPos.y % scaledGridSize;
// Finally, calculate the position by rounding to the grid and adding the offset.
return {
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
};
},
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
}),
};
this.konva.rect.on('dragmove', () => {
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
x: roundToMultiple(this.konva.rect.x(), gridSize),
y: roundToMultiple(this.konva.rect.y(), gridSize),
};
this.konva.rect.setAttrs(bboxRect);
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
this.manager.stateApi.setGenerationBbox(bboxRect);
}
});
this.konva.transformer.on('transform', () => {
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
// Some special handling is needed depending on the anchor being dragged.
const anchor = this.konva.transformer.getActiveAnchor();
if (!anchor) {
// Pretty sure we should always have an anchor here?
return;
}
this.konva.proxyRect.on('dragmove', this.onDragMove);
this.konva.transformer.on('transform', this.onTransform);
this.konva.transformer.on('transformend', this.onTransformEnd);
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}
// The coords should be correct per the anchorDragBoundFunc.
let x = this.konva.rect.x();
let y = this.konva.rect.y();
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
// them to the grid.
let width = roundToMultipleMin(this.konva.rect.width() * this.konva.rect.scaleX(), gridSize);
let height = roundToMultipleMin(this.konva.rect.height() * this.konva.rect.scaleY(), gridSize);
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
// if alt/opt is held - this requires math too big for my brain.
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
// Fit the bbox to the last aspect ratio
let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get());
let fittedHeight = fittedWidth / $aspectRatioBuffer.get();
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
// We need to adjust the x and y coords to have the resize occur from the right origin.
if (anchor === 'top-left') {
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
x = x - (fittedWidth - width);
y = y - (fittedHeight - height);
}
if (anchor === 'top-right') {
// The transform origin is the bottom-left anchor. Only y needs to be updated.
y = y - (fittedHeight - height);
}
if (anchor === 'bottom-left') {
// The transform origin is the top-right anchor. Only x needs to be updated.
x = x - (fittedWidth - width);
}
// Update the width and height to the fitted dims.
width = fittedWidth;
height = fittedHeight;
}
const bboxRect = {
x: Math.round(x),
y: Math.round(y),
width,
height,
};
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
// TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly.
// Gotta be a way to avoid setting it twice...
this.konva.rect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
this.manager.stateApi.setGenerationBbox(bboxRect);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
if (!shift) {
$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
}
});
this.konva.transformer.on('transformend', () => {
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
// we have the correct aspect ratio to start from.
$aspectRatioBuffer.set(this.konva.rect.width() / this.konva.rect.height());
});
// The transformer will always be transforming the dummy rect
this.konva.transformer.nodes([this.konva.rect]);
this.konva.group.add(this.konva.rect);
// The transformer will always be transforming the proxy rect
this.konva.transformer.nodes([this.konva.proxyRect]);
this.konva.group.add(this.konva.proxyRect);
this.konva.group.add(this.konva.transformer);
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render));
// We will listen to the tool state to determine if the bbox should be visible or not.
this.subscriptions.add(this.manager.tool.$tool.listen(this.render));
}
/**
* Renders the bbox. The bbox is only visible when the tool is set to 'bbox'.
*/
render = () => {
this.log.trace('Rendering bbox module');
this.log.trace('Rendering');
const bbox = this.manager.stateApi.getBbox();
const tool = this.manager.stateApi.$tool.get();
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const tool = this.manager.tool.$tool.get();
this.konva.group.visible(true);
this.parent.getLayer().listening(tool === 'bbox');
this.konva.group.listening(tool === 'bbox');
this.konva.rect.setAttrs({
x: bbox.rect.x,
y: bbox.rect.y,
width: bbox.rect.width,
height: bbox.rect.height,
// We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with.
this.manager.konva.previewLayer.listening(tool === 'bbox');
this.konva.proxyRect.setAttrs({
x,
y,
width,
height,
scaleX: 1,
scaleY: 1,
listening: tool === 'bbox',
@@ -260,21 +144,174 @@ export class CanvasBboxModule extends CanvasModuleABC {
});
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.trace('Destroying bbox module');
this.log.trace('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
getLoggingContext = (): SerializableObject => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
/**
* Handles the dragmove event on the bbox rect:
* - Snaps the bbox position to the grid (determined by ctrl/meta key)
* - Pushes the new bbox rect into app state
*/
onDragMove = () => {
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
x: roundToMultiple(this.konva.proxyRect.x(), gridSize),
y: roundToMultiple(this.konva.proxyRect.y(), gridSize),
};
this.konva.proxyRect.setAttrs(bboxRect);
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
this.manager.stateApi.setGenerationBbox(bboxRect);
}
};
/**
* Handles the transform event on the bbox transformer:
* - Snaps the bbox dimensions to the grid (determined by ctrl/meta key)
* - Centered scaling when alt is held
* - Aspect ratio locking when shift is held
* - Pushes the new bbox rect into app state
* - Syncs the aspect ratio buffer
*/
onTransform = () => {
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
// Some special handling is needed depending on the anchor being dragged.
const anchor = this.konva.transformer.getActiveAnchor();
if (!anchor) {
// Pretty sure we should always have an anchor here?
return;
}
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}
// The coords should be correct per the anchorDragBoundFunc.
let x = this.konva.proxyRect.x();
let y = this.konva.proxyRect.y();
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
// them to the grid.
let width = roundToMultipleMin(this.konva.proxyRect.width() * this.konva.proxyRect.scaleX(), gridSize);
let height = roundToMultipleMin(this.konva.proxyRect.height() * this.konva.proxyRect.scaleY(), gridSize);
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
// if alt/opt is held - this requires math too big for my brain.
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
// Fit the bbox to the last aspect ratio
let fittedWidth = Math.sqrt(width * height * this.$aspectRatioBuffer.get());
let fittedHeight = fittedWidth / this.$aspectRatioBuffer.get();
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
// We need to adjust the x and y coords to have the resize occur from the right origin.
if (anchor === 'top-left') {
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
x = x - (fittedWidth - width);
y = y - (fittedHeight - height);
}
if (anchor === 'top-right') {
// The transform origin is the bottom-left anchor. Only y needs to be updated.
y = y - (fittedHeight - height);
}
if (anchor === 'bottom-left') {
// The transform origin is the top-right anchor. Only x needs to be updated.
x = x - (fittedWidth - width);
}
// Update the width and height to the fitted dims.
width = fittedWidth;
height = fittedHeight;
}
const bboxRect = {
x: Math.round(x),
y: Math.round(y),
width,
height,
};
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
this.konva.proxyRect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
this.manager.stateApi.setGenerationBbox(bboxRect);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
if (!shift) {
this.$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
}
};
/**
* Handles the transformend event on the bbox transformer:
* - Updates the aspect ratio buffer with the new aspect ratio
*/
onTransformEnd = () => {
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
// we have the correct aspect ratio to start from.
this.$aspectRatioBuffer.set(this.konva.proxyRect.width() / this.konva.proxyRect.height());
};
/**
* This function is called for each anchor on the transformer. It sets the style of the anchor based on its name.
* We make the x/y resize anchors little bars.
*/
anchorStyleFunc = (anchor: Konva.Rect): void => {
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
anchor.height(8);
anchor.offsetY(4);
anchor.width(30);
anchor.offsetX(15);
}
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
anchor.height(30);
anchor.offsetY(15);
anchor.width(8);
anchor.offsetX(4);
}
};
/**
* This function is called for each anchor on the transformer. It sets the drag bounds for the anchor based on the
* stage's position and the grid size. Care is taken to ensure the anchor snaps to the grid correctly.
*/
anchorDragBoundFunc = (oldAbsPos: Coordinate, newAbsPos: Coordinate): Coordinate => {
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
// to konva's internal coordinate system.
const stage = this.konva.transformer.getStage();
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
const stageAbsPos = stage.getAbsolutePosition();
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
const offsetX = stageAbsPos.x % scaledGridSize;
const offsetY = stageAbsPos.y % scaledGridSize;
// Finally, calculate the position by rounding to the grid and adding the offset.
return {
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
};
};
}

View File

@@ -0,0 +1,146 @@
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type BrushToolPreviewConfig = {
/**
* The inner border color for the brush tool preview.
*/
BORDER_INNER_COLOR: string;
/**
* The outer border color for the brush tool preview.
*/
BORDER_OUTER_COLOR: string;
};
const DEFAULT_CONFIG: BrushToolPreviewConfig = {
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
};
/**
* Renders a preview of the brush tool on the canvas.
*/
export class CanvasBrushToolPreview extends CanvasModuleBase {
readonly type = 'brush_tool_preview';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
config: BrushToolPreviewConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the brush tool preview:
* - A group to hold the fill circle and borders
* - A circle to fill the brush area
* - An inner border ring
* - An outer border ring
*/
konva: {
group: Konva.Group;
fillCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:brush_group`, listening: false }),
fillCircle: new Konva.Circle({
name: `${this.type}:brush_fill_circle`,
listening: false,
strokeEnabled: false,
}),
innerBorder: new Konva.Ring({
name: `${this.type}:brush_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:brush_outer_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
};
this.konva.group.add(this.konva.fillCircle, this.konva.innerBorder, this.konva.outerBorder);
}
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
// If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity.
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
});
// But the borders are in screen-pixels
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
}

View File

@@ -1,54 +1,92 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { GenerationMode } from 'features/controlLayers/store/types';
import { LRUCache } from 'lru-cache';
import type { Logger } from 'roarr';
export class CanvasCacheModule extends CanvasModuleABC {
type CanvasCacheModuleConfig = {
/**
* The maximum size of the image name cache.
*/
imageNameCacheSize: number;
/**
* The maximum size of the canvas element cache.
*/
canvasElementCacheSize: number;
/**
* The maximum size of the generation mode cache.
*/
generationModeCacheSize: number;
};
const DEFAULT_CONFIG: CanvasCacheModuleConfig = {
imageNameCacheSize: 100,
canvasElementCacheSize: 32,
generationModeCacheSize: 100,
};
/**
* A cache module for storing the results of expensive calculations. For example, when we rasterize a layer and upload
* it to the server, we store the resultant image name in this cache for future use.
*/
export class CanvasCacheModule extends CanvasModuleBase {
readonly type = 'cache';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
imageNameCache = new LRUCache<string, string>({ max: 100 });
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: 32 });
generationModeCache = new LRUCache<string, GenerationMode>({ max: 100 });
config: CanvasCacheModuleConfig = DEFAULT_CONFIG;
/**
* A cache for storing image names. Used as a cache for results of layer/canvas/entity exports. For example, when we
* rasterize a layer and upload it to the server, we store the image name in this cache.
*
* The cache key is a hash of the exported entity's state and the export rect.
*/
imageNameCache = new LRUCache<string, string>({ max: this.config.imageNameCacheSize });
/**
* A cache for storing canvas elements. Similar to the image name cache, but for canvas elements. The primary use is
* for caching composite layers. For example, the canvas compositor module uses this to store the canvas elements for
* individual raster layers when creating a composite of the layers.
*
* The cache key is a hash of the exported entity's state and the export rect.
*/
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: this.config.canvasElementCacheSize });
/**
* A cache for the generation mode calculation, which is fairly expensive.
*
* The cache key is a hash of all the objects that contribute to the generation mode calculation (e.g. the composite
* raster layer, the composite inpaint mask, and bounding box), and the value is the generation mode.
*/
generationModeCache = new LRUCache<string, GenerationMode>({ max: this.config.generationModeCacheSize });
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('cache');
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating cache module');
}
/**
* Clears all caches.
*/
clearAll = () => {
this.canvasElementCache.clear();
this.imageNameCache.clear();
this.generationModeCache.clear();
};
repr = () => {
return {
id: this.id,
path: this.path,
type: this.type,
};
};
destroy = () => {
this.log.debug('Destroying cache module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.clearAll();
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -0,0 +1,293 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type ColorPickerToolConfig = {
/**
* The inner radius of the ring.
*/
RING_INNER_RADIUS: number;
/**
* The outer radius of the ring.
*/
RING_OUTER_RADIUS: number;
/**
* The inner border color of the outside edge of ring.
*/
RING_BORDER_INNER_COLOR: string;
/**
* The outer border color of the outside edge of ring.
*/
RING_BORDER_OUTER_COLOR: string;
/**
* The radius of the space between the center of the ring and start of the crosshair lines.
*/
CROSSHAIR_INNER_RADIUS: number;
/**
* The length of the crosshair lines.
*/
CROSSHAIR_LINE_LENGTH: number;
/**
* The thickness of the crosshair lines.
*/
CROSSHAIR_LINE_THICKNESS: number;
/**
* The color of the crosshair lines.
*/
CROSSHAIR_LINE_COLOR: string;
/**
* The thickness of the crosshair lines borders
*/
CROSSHAIR_LINE_BORDER_THICKNESS: number;
/**
* The color of the crosshair line borders.
*/
CROSSHAIR_BORDER_COLOR: string;
};
const DEFAULT_CONFIG: ColorPickerToolConfig = {
RING_INNER_RADIUS: 25,
RING_OUTER_RADIUS: 35,
RING_BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
RING_BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
CROSSHAIR_INNER_RADIUS: 5,
CROSSHAIR_LINE_THICKNESS: 1.5,
CROSSHAIR_LINE_BORDER_THICKNESS: 0.75,
CROSSHAIR_LINE_LENGTH: 10,
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
};
/**
* Renders a preview of the color picker tool on the canvas.
*/
export class CanvasColorPickerToolPreview extends CanvasModuleBase {
readonly type = 'color_picker_tool_preview';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
config: ColorPickerToolConfig = DEFAULT_CONFIG;
/**
* The Konva objects that make up the color picker tool preview:
* - A group to hold all the objects
* - A ring that shows the candidate and current color
* - A crosshair to help with color selection
*/
konva: {
group: Konva.Group;
ringCandidateColor: Konva.Ring;
ringCurrentColor: Konva.Arc;
ringInnerBorder: Konva.Ring;
ringOuterBorder: Konva.Ring;
crosshairNorthInner: Konva.Line;
crosshairNorthOuter: Konva.Line;
crosshairEastInner: Konva.Line;
crosshairEastOuter: Konva.Line;
crosshairSouthInner: Konva.Line;
crosshairSouthOuter: Konva.Line;
crosshairWestInner: Konva.Line;
crosshairWestOuter: Konva.Line;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
ringCandidateColor: new Konva.Ring({
name: `${this.type}:color_picker_candidate_color_ring`,
innerRadius: 0,
outerRadius: 0,
strokeEnabled: false,
}),
ringCurrentColor: new Konva.Arc({
name: `${this.type}:color_picker_current_color_arc`,
innerRadius: 0,
outerRadius: 0,
angle: 180,
strokeEnabled: false,
}),
ringInnerBorder: new Konva.Ring({
name: `${this.type}:color_picker_inner_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.RING_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
ringOuterBorder: new Konva.Ring({
name: `${this.type}:color_picker_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.RING_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
crosshairNorthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairNorthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairEastInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairEastOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairSouthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairSouthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairWestInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairWestOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
};
this.konva.group.add(
this.konva.ringCandidateColor,
this.konva.ringCurrentColor,
this.konva.ringInnerBorder,
this.konva.ringOuterBorder,
this.konva.crosshairNorthOuter,
this.konva.crosshairNorthInner,
this.konva.crosshairEastOuter,
this.konva.crosshairEastInner,
this.konva.crosshairSouthOuter,
this.konva.crosshairSouthInner,
this.konva.crosshairWestOuter,
this.konva.crosshairWestInner
);
}
/**
* Renders the color picker tool preview on the canvas.
*/
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
// If the cursor position is not available, do not render the preview. The tool module will handle visibility.
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const colorUnderCursor = this.parent.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(this.config.RING_INNER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(this.config.RING_OUTER_RADIUS);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.ringCandidateColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.ringCurrentColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.ringInnerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.ringOuterBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_LENGTH);
const space = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_INNER_RADIUS);
const innerThickness = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_THICKNESS);
const outerThickness = this.manager.stage.getScaledPixels(
this.config.CROSSHAIR_LINE_THICKNESS + this.config.CROSSHAIR_LINE_BORDER_THICKNESS * 2
);
this.konva.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying color picker tool preview module');
this.konva.group.destroy();
};
}

View File

@@ -1,6 +1,6 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import {
canvasToBlob,
canvasToImageData,
@@ -15,24 +15,36 @@ import type { ImageDTO } from 'services/api/types';
import stableHash from 'stable-hash';
import { assert } from 'tsafe';
export class CanvasCompositorModule extends CanvasModuleABC {
/**
* Handles compositing operations:
* - Rasterizing and uploading the composite raster layer
* - Rasterizing and uploading the composite inpaint mask
* - Caclulating the generation mode (which requires the composite raster layer and inpaint mask)
*/
export class CanvasCompositorModule extends CanvasModuleBase {
readonly type = 'compositor';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('canvas_compositor');
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating compositor module');
}
/**
* Gets the entity IDs of all raster layers that should be included in the composite raster layer.
* A raster layer is included if it is enabled and has objects.
* @returns An array of raster layer entity IDs
*/
getCompositeRasterLayerEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.rasterLayers.values()) {
@@ -43,16 +55,35 @@ export class CanvasCompositorModule extends CanvasModuleABC {
return ids;
};
getCompositeInpaintMaskEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
ids.push(adapter.id);
/**
* Gets a hash of the composite raster layer, which includes the state of all raster layers that are included in the
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
* @param extra Any extra data to include in the hash
* @returns A hash for the composite raster layer
*/
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeRasterLayerEntityIds()) {
const adapter = this.manager.adapters.rasterLayers.get(id);
if (!adapter) {
this.log.warn({ id }, 'Raster layer adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return ids;
return stableHash(data);
};
/**
* Gets a canvas element for the composite raster layer. Only the region defined by the rect is included in the canvas.
*
* If the hash of the composite raster layer is found in the cache, the cached canvas is returned.
*
* @param rect The region to include in the canvas
* @returns A canvas element with the composite raster layer drawn on it
*/
getCompositeRasterLayerCanvas = (rect: Rect): HTMLCanvasElement => {
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
@@ -85,6 +116,99 @@ export class CanvasCompositorModule extends CanvasModuleABC {
return canvas;
};
/**
* Rasterizes the composite raster layer and uploads it to the server.
*
* If the hash of the composite raster layer is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the rasterized image
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
* @returns A promise that resolves to the uploaded image DTO
*/
rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean): Promise<ImageDTO> => {
this.log.trace({ rect }, 'Rasterizing composite raster layer');
const canvas = this.getCompositeRasterLayerCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite raster layer canvas');
}
return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery);
};
/**
* Gets the image DTO for the composite raster layer.
*
* If the image is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the image
* @returns A promise that resolves to the image DTO
*/
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
if (cachedImageName) {
imageDTO = await getImageDTO(cachedImageName);
if (imageDTO) {
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
return imageDTO;
}
}
imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
return imageDTO;
};
/**
* Gets the entity IDs of all inpaint masks that should be included in the composite inpaint mask.
* An inpaint mask is included if it is enabled and has objects.
* @returns An array of inpaint mask entity IDs
*/
getCompositeInpaintMaskEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
ids.push(adapter.id);
}
}
return ids;
};
/**
* Gets a hash of the composite inpaint mask, which includes the state of all inpaint masks that are included in the
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
* @param extra Any extra data to include in the hash
* @returns A hash for the composite inpaint mask
*/
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeInpaintMaskEntityIds()) {
const adapter = this.manager.adapters.inpaintMasks.get(id);
if (!adapter) {
this.log.warn({ id }, 'Inpaint mask adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
/**
* Gets a canvas element for the composite inpaint mask. Only the region defined by the rect is included in the canvas.
*
* If the hash of the composite inpaint mask is found in the cache, the cached canvas is returned.
*
* @param rect The region to include in the canvas
* @returns A canvas element with the composite inpaint mask drawn on it
*/
getCompositeInpaintMaskCanvas = (rect: Rect): HTMLCanvasElement => {
const hash = this.getCompositeInpaintMaskHash({ rect });
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
@@ -117,68 +241,15 @@ export class CanvasCompositorModule extends CanvasModuleABC {
return canvas;
};
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeRasterLayerEntityIds()) {
const adapter = this.manager.adapters.rasterLayers.get(id);
if (!adapter) {
this.log.warn({ id }, 'Raster layer adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
extra,
};
for (const id of this.getCompositeInpaintMaskEntityIds()) {
const adapter = this.manager.adapters.inpaintMasks.get(id);
if (!adapter) {
this.log.warn({ id }, 'Inpaint mask adapter not found');
continue;
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
};
rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean) => {
this.log.trace({ rect }, 'Rasterizing composite raster layer');
const canvas = this.getCompositeRasterLayerCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite raster layer canvas');
}
return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery);
};
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
const hash = this.getCompositeRasterLayerHash({ rect });
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
if (cachedImageName) {
imageDTO = await getImageDTO(cachedImageName);
if (imageDTO) {
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
return imageDTO;
}
}
imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
return imageDTO;
};
/**
* Rasterizes the composite inpaint mask and uploads it to the server.
*
* If the hash of the composite inpaint mask is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the rasterized image
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
* @returns A promise that resolves to the uploaded image DTO
*/
rasterizeAndUploadCompositeInpaintMask = async (rect: Rect, saveToGallery: boolean) => {
this.log.trace({ rect }, 'Rasterizing composite inpaint mask');
@@ -191,6 +262,14 @@ export class CanvasCompositorModule extends CanvasModuleABC {
return uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery);
};
/**
* Gets the image DTO for the composite inpaint mask.
*
* If the image is found in the cache, the cached image DTO is returned.
*
* @param rect The region to include in the image
* @returns A promise that resolves to the image DTO
*/
getCompositeInpaintMaskImageDTO = async (rect: Rect): Promise<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
@@ -210,6 +289,24 @@ export class CanvasCompositorModule extends CanvasModuleABC {
return imageDTO;
};
/**
* Calculates the generation mode for the current canvas state. This is determined by the transparency of the
* composite raster layer and composite inpaint mask:
* - Composite raster layer is fully transparent -> txt2img
* - Composite raster layer is partially transparent -> outpainting
* - Composite raster layer is opaque & composite inpaint mask is fully transparent -> img2img
* - Composite raster layer is opaque & composite inpaint mask is partially transparent -> inpainting
*
* Definitions:
* - Fully transparent: all pixels have an alpha value of 0.
* - Partially transparent: at least one pixel with an alpha value of 0 & at least one pixel with an alpha value
* greater than 0.
* - Opaque: all pixels have an alpha value greater than 0.
*
* The generation mode is cached to avoid recalculating it when the canvas state has not changed.
*
* @returns The generation mode
*/
getGenerationMode(): GenerationMode {
const { rect } = this.manager.stateApi.getBbox();
@@ -250,21 +347,4 @@ export class CanvasCompositorModule extends CanvasModuleABC {
this.manager.cache.generationModeCache.set(hash, generationMode);
return generationMode;
}
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.trace('Destroying compositor module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
import type {
CanvasBrushLineState,
@@ -22,33 +22,63 @@ import type { Logger } from 'roarr';
import stableHash from 'stable-hash';
import { assert } from 'tsafe';
export class CanvasEntityLayerAdapter extends CanvasModuleABC {
/**
* Handles the rendering for a single raster or control layer entity.
*
* This module has two main components:
* - A transformer, which handles the positioning and interaction state of the layer
* - A renderer, which handles the rendering of the layer's objects
*
* The canvas rendering module interacts with this module to coordinate the rendering of all raster and control layers.
*/
export class CanvasEntityLayerAdapter extends CanvasModuleBase {
readonly type = 'entity_layer_adapter';
id: string;
path: string[];
manager: CanvasManager;
subscriptions = new Set<() => void>();
parent: CanvasManager;
log: Logger;
/**
* The last known state of the entity.
*/
state: CanvasRasterLayerState | CanvasControlLayerState;
/**
* The Konva nodes that make up the entity layer:
* - A layer to hold the everything
*
* Note that the transformer and object renderer have their own Konva nodes, but they are not stored here.
*/
konva: {
layer: Konva.Layer;
};
/**
* The transformer for this entity layer.
*/
transformer: CanvasEntityTransformer;
/**
* The renderer for this entity layer.
*/
renderer: CanvasEntityRenderer;
/**
* Whether this is the first render of the entity layer.
*/
isFirstRender: boolean = true;
constructor(state: CanvasEntityLayerAdapter['state'], manager: CanvasEntityLayerAdapter['manager']) {
super();
this.id = state.id;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating layer adapter module');
this.log.debug('Creating module');
this.state = state;
@@ -74,15 +104,7 @@ export class CanvasEntityLayerAdapter extends CanvasModuleABC {
return getEntityIdentifier(this.state);
};
destroy = (): void => {
this.log.debug('Destroying layer adapter module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.renderer.destroy();
this.transformer.destroy();
this.konva.layer.destroy();
};
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }) => {
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }): Promise<void> => {
const state = get(arg, 'state', this.state);
const prevState = this.state;
@@ -144,23 +166,7 @@ export class CanvasEntityLayerAdapter extends CanvasModuleABC {
}
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),
};
};
getLoggingContext = (): SerializableObject => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
getCanvas = (rect?: Rect): HTMLCanvasElement => {
// TODO(psyche) - cache this - maybe with package `memoizee`? Would require careful review of cache invalidation
this.log.trace({ rect }, 'Getting canvas');
// The opacity may have been changed in response to user selecting a different entity category, so we must restore
// the original opacity before rendering the canvas
@@ -202,30 +208,21 @@ export class CanvasEntityLayerAdapter extends CanvasModuleABC {
return null;
};
logDebugInfo(msg = 'Debug info') {
const info = {
repr: this.repr(),
interactionRectAttrs: {
x: this.transformer.konva.proxyRect.x(),
y: this.transformer.konva.proxyRect.y(),
scaleX: this.transformer.konva.proxyRect.scaleX(),
scaleY: this.transformer.konva.proxyRect.scaleY(),
width: this.transformer.konva.proxyRect.width(),
height: this.transformer.konva.proxyRect.height(),
rotation: this.transformer.konva.proxyRect.rotation(),
},
objectGroupAttrs: {
x: this.renderer.konva.objectGroup.x(),
y: this.renderer.konva.objectGroup.y(),
scaleX: this.renderer.konva.objectGroup.scaleX(),
scaleY: this.renderer.konva.objectGroup.scaleY(),
width: this.renderer.konva.objectGroup.width(),
height: this.renderer.konva.objectGroup.height(),
rotation: this.renderer.konva.objectGroup.rotation(),
offsetX: this.renderer.konva.objectGroup.offsetX(),
offsetY: this.renderer.konva.objectGroup.offsetY(),
},
destroy = (): void => {
this.log.debug('Destroying module');
this.renderer.destroy();
this.transformer.destroy();
this.konva.layer.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),
};
this.log.trace(info, msg);
}
};
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
import type {
CanvasBrushLineState,
@@ -21,14 +21,14 @@ import { get, omit } from 'lodash-es';
import type { Logger } from 'roarr';
import stableHash from 'stable-hash';
export class CanvasEntityMaskAdapter extends CanvasModuleABC {
export class CanvasEntityMaskAdapter extends CanvasModuleBase {
readonly type = 'entity_mask_adapter';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasInpaintMaskState | CanvasRegionalGuidanceState;
@@ -44,11 +44,12 @@ export class CanvasEntityMaskAdapter extends CanvasModuleABC {
constructor(state: CanvasEntityMaskAdapter['state'], manager: CanvasEntityMaskAdapter['manager']) {
super();
this.id = state.id;
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating mask adapter module');
this.log.debug('Creating module');
this.state = state;
@@ -74,14 +75,6 @@ export class CanvasEntityMaskAdapter extends CanvasModuleABC {
return getEntityIdentifier(this.state);
};
destroy = (): void => {
this.log.debug('Destroying mask adapter module');
this.transformer.destroy();
this.renderer.destroy();
this.konva.layer.destroy();
};
update = async (arg?: { state: CanvasEntityMaskAdapter['state'] }) => {
const state = get(arg, 'state', this.state);
@@ -157,15 +150,6 @@ export class CanvasEntityMaskAdapter extends CanvasModuleABC {
return null;
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
};
};
getHashableState = (): SerializableObject => {
const keysToOmit: (keyof CanvasEntityMaskAdapter['state'])[] = ['fill', 'name', 'opacity'];
return omit(this.state, keysToOmit);
@@ -180,7 +164,6 @@ export class CanvasEntityMaskAdapter extends CanvasModuleABC {
};
getCanvas = (rect?: Rect): HTMLCanvasElement => {
// TODO(psyche): Cache this?
// The opacity may have been changed in response to user selecting a different entity category, and the mask regions
// should be fully opaque - set opacity to 1 before rendering the canvas
const attrs: GroupConfig = { opacity: 1 };
@@ -188,7 +171,19 @@ export class CanvasEntityMaskAdapter extends CanvasModuleABC {
return canvas;
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
destroy = () => {
this.log.debug('Destroying module');
this.transformer.destroy();
this.renderer.destroy();
this.konva.layer.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
};
};
}

View File

@@ -2,7 +2,7 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectBrushLineRenderer } from 'features/controlLayers/konva/CanvasObjectBrushLineRenderer';
import { CanvasObjectEraserLineRenderer } from 'features/controlLayers/konva/CanvasObjectEraserLineRenderer';
import { CanvasObjectImageRenderer } from 'features/controlLayers/konva/CanvasObjectImageRenderer';
@@ -60,7 +60,7 @@ type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImage
/**
* Handles rendering of objects for a canvas entity.
*/
export class CanvasEntityRenderer extends CanvasModuleABC {
export class CanvasEntityRenderer extends CanvasModuleBase {
readonly type = 'entity_renderer';
id: string;
@@ -125,16 +125,25 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
} | null;
};
/**
* The entity's object group as a canvas element along with the pixel rect of the entity at the time the canvas was
* drawn.
*
* Technically, this is an internal Konva object, created when a Konva node's `.cache()` method is called. We cache
* the object group after every update, so we get this as a "free" side effect.
*
* This is used to render the entity's preview in the control layer.
*/
$canvasCache = atom<{ canvas: HTMLCanvasElement; rect: Rect } | null>(null);
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.path = this.parent.path.concat(this.id);
this.manager = parent.manager;
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating entity object renderer module');
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }),
@@ -166,7 +175,7 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
// user switches tool mid-drawing, for example by pressing space to pan the stage. It's easy to press space
// to pan _before_ releasing the mouse button, which would cause the buffer to be lost if we didn't commit it.
this.subscriptions.add(
this.manager.stateApi.$tool.listen(() => {
this.manager.tool.$tool.listen(() => {
this.commitBuffer();
})
);
@@ -174,7 +183,7 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
// The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we
// need to update the compositing rect to match the stage.
this.subscriptions.add(
this.manager.stateApi.$stageAttrs.listen(() => {
this.manager.stage.$stageAttrs.listen(() => {
if (this.konva.compositing && this.parent.type === 'entity_mask_adapter') {
this.updateCompositingRectSize();
}
@@ -247,7 +256,7 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
this.log.trace('Updating compositing rect size');
assert(this.konva.compositing, 'Missing compositing rect');
const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get();
const { x, y, width, height, scale } = this.manager.stage.$stageAttrs.get();
this.konva.compositing.rect.setAttrs({
x: -x / scale,
@@ -549,17 +558,18 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
};
updatePreviewCanvas = debounce(() => {
if (this.parent.transformer.isPendingRectCalculation) {
if (this.parent.transformer.$isPendingRectCalculation.get()) {
return;
}
if (this.parent.transformer.pixelRect.width === 0 || this.parent.transformer.pixelRect.height === 0) {
const pixelRect = this.parent.transformer.$pixelRect.get();
if (pixelRect.width === 0 || pixelRect.height === 0) {
return;
}
try {
// TODO(psyche): This is an internal Konva method, so it may break in the future. Can we make this API public?
const canvas = this.konva.objectGroup._getCachedSceneCanvas()._canvas as HTMLCanvasElement | undefined | null;
if (canvas) {
const nodeRect = this.parent.transformer.nodeRect;
const pixelRect = this.parent.transformer.pixelRect;
const nodeRect = this.parent.transformer.$nodeRect.get();
const rect = {
x: pixelRect.x - nodeRect.x,
y: pixelRect.y - nodeRect.y,
@@ -604,11 +614,8 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
return imageData;
};
/**
* Destroys this renderer and all of its object renderers.
*/
destroy = () => {
this.log.debug('Destroying entity object renderer module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
for (const renderer of this.renderers.values()) {
renderer.destroy();
@@ -616,10 +623,6 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
this.renderers.clear();
};
/**
* Gets a serializable representation of the renderer.
* @returns A serializable representation of the renderer.
*/
repr = () => {
return {
id: this.id,
@@ -630,8 +633,4 @@ export class CanvasEntityRenderer extends CanvasModuleABC {
buffer: this.bufferRenderer?.repr(),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,66 +1,116 @@
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { canvasToImageData, getEmptyRect, getPrefixedId } from 'features/controlLayers/konva/util';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import type { Coordinate, Rect, RectWithRotation } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import { debounce, get } from 'lodash-es';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
/**
* The CanvasTransformer class is responsible for managing the transformation of a canvas entity:
* - Moving
* - Resizing
* - Rotating
*
* It renders an outline when dragging and resizing the entity, with transform anchors for resizing and rotation.
*/
export class CanvasEntityTransformer extends CanvasModuleABC {
type CanvasEntityTransformerConfig = {
/**
* The debounce time in milliseconds for calculating the rect of the parent entity
*/
RECT_CALC_DEBOUNCE_MS: number;
/**
* The padding around the scaling transform anchors for hit detection
*/
ANCHOR_HIT_PADDING: number;
/**
* The padding around the parent entity when drawing the rect outline
*/
OUTLINE_PADDING: number;
/**
* The color of the rect outline
*/
OUTLINE_COLOR: string;
/**
* The fill color of the scaling transform anchors
*/
SCALE_ANCHOR_FILL_COLOR: string;
/**
* The stroke color of the scaling transform anchors
*/
SCALE_ANCHOR_STROKE_COLOR: string;
/**
* The corner radius ratio of the scaling transform anchors
*/
SCALE_ANCHOR_CORNER_RADIUS_RATIO: number;
/**
* The stroke width of the scaling transform anchors
*/
SCALE_ANCHOR_STROKE_WIDTH: number;
/**
* The size of the scaling transform anchors
*/
SCALE_ANCHOR_SIZE: number;
/**
* The fill color of the rotation transform anchor
*/
ROTATE_ANCHOR_FILL_COLOR: string;
/**
* The stroke color of the rotation transform anchor
*/
ROTATE_ANCHOR_STROKE_COLOR: string;
/**
* The size (height/width) of the rotation transform anchor
*/
ROTATE_ANCHOR_SIZE: number;
};
const DEFAULT_CONFIG: CanvasEntityTransformerConfig = {
RECT_CALC_DEBOUNCE_MS: 300,
ANCHOR_HIT_PADDING: 10,
OUTLINE_PADDING: 0,
OUTLINE_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
SCALE_ANCHOR_FILL_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
SCALE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 77% / 1)', // invokeBlue.200
SCALE_ANCHOR_CORNER_RADIUS_RATIO: 0.5,
SCALE_ANCHOR_STROKE_WIDTH: 2,
SCALE_ANCHOR_SIZE: 8,
ROTATE_ANCHOR_FILL_COLOR: 'hsl(200 76% 95% / 1)', // invokeBlue.50
ROTATE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 40% / 1)', // invokeBlue.700
ROTATE_ANCHOR_SIZE: 12,
};
export class CanvasEntityTransformer extends CanvasModuleBase {
readonly type = 'entity_transformer';
static RECT_CALC_DEBOUNCE_MS = 300;
static OUTLINE_PADDING = 0;
static OUTLINE_COLOR = 'hsl(200 76% 50% / 1)'; // invokeBlue.500
static ANCHOR_FILL_COLOR = CanvasEntityTransformer.OUTLINE_COLOR;
static ANCHOR_STROKE_COLOR = 'hsl(200 76% 77% / 1)'; // invokeBlue.200
static ANCHOR_CORNER_RADIUS_RATIO = 0.5;
static ANCHOR_STROKE_WIDTH = 2;
static ANCHOR_HIT_PADDING = 10;
static RESIZE_ANCHOR_SIZE = 8;
static ROTATE_ANCHOR_FILL_COLOR = 'hsl(200 76% 95% / 1)'; // invokeBlue.50
static ROTATE_ANCHOR_STROKE_COLOR = 'hsl(200 76% 40% / 1)'; // invokeBlue.700
static ROTATE_ANCHOR_SIZE = 12;
id: string;
path: string[];
parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter;
manager: CanvasManager;
log: Logger;
config: CanvasEntityTransformerConfig = DEFAULT_CONFIG;
/**
* The rect of the parent, _including_ transparent regions.
* It is calculated via Konva's getClientRect method, which is fast but includes transparent regions.
*
* Stored as a nanostores atom for easy reactivity.
*/
nodeRect = getEmptyRect();
$nodeRect = atom<Rect>(getEmptyRect());
/**
* The rect of the parent, _excluding_ transparent regions.
* If the parent's nodes have no possibility of transparent regions, this will be calculated the same way as nodeRect.
* If the parent's nodes may have transparent regions, this will be calculated manually by rasterizing the parent and
* checking the pixel data.
*
* Stored as a nanostores atom for easy reactivity.
*/
pixelRect = getEmptyRect();
$pixelRect = atom<Rect>(getEmptyRect());
/**
* Whether the transformer is currently calculating the rect of the parent.
*
* Stored as a nanostores atom for easy reactivity.
*/
isPendingRectCalculation: boolean = true;
$isPendingRectCalculation = atom<boolean>(true);
/**
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
@@ -69,27 +119,40 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
/**
* Whether the transformer is currently transforming the entity.
*
* Stored as a nanostores atom for easy reactivity.
*/
isTransforming: boolean = false;
$isTransforming = atom<boolean>(false);
/**
* The current interaction mode of the transformer:
* - 'all': The entity can be moved, resized, and rotated.
* - 'drag': The entity can be moved.
* - 'off': The transformer is not interactable.
*
* Stored as a nanostores atom for easy reactivity.
*/
interactionMode: 'all' | 'drag' | 'off' = 'off';
$interactionMode = atom<'all' | 'drag' | 'off'>('off');
/**
* Whether dragging is enabled. Dragging is enabled in both 'all' and 'drag' interaction modes.
*
* Stored as a nanostores atom for easy reactivity.
*/
isDragEnabled: boolean = false;
$isDragEnabled = atom<boolean>(false);
/**
* Whether transforming is enabled. Transforming is enabled only in 'all' interaction mode.
*
* Stored as a nanostores atom for easy reactivity.
*/
isTransformEnabled: boolean = false;
$isTransformEnabled = atom<boolean>(false);
/**
* Whether the transformer is currently processing (rasterizing and uploading) the transformed entity.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isProcessing = atom(false);
konva: {
@@ -98,21 +161,22 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
outlineRect: Konva.Rect;
};
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
constructor(parent: CanvasEntityTransformer['parent']) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating entity transformer module');
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
outlineRect: new Konva.Rect({
listening: false,
draggable: false,
name: `${this.type}:outline_rect`,
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
stroke: this.config.OUTLINE_COLOR,
perfectDrawEnabled: false,
strokeHitEnabled: false,
}),
@@ -128,111 +192,18 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
// Transforming will retain aspect ratio only when shift is held
keepRatio: false,
// The padding is the distance between the transformer bbox and the nodes
padding: CanvasEntityTransformer.OUTLINE_PADDING,
padding: this.config.OUTLINE_PADDING,
// This is `invokeBlue.400`
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
anchorFill: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
anchorStroke: CanvasEntityTransformer.ANCHOR_STROKE_COLOR,
anchorStrokeWidth: CanvasEntityTransformer.ANCHOR_STROKE_WIDTH,
anchorSize: CanvasEntityTransformer.RESIZE_ANCHOR_SIZE,
anchorCornerRadius:
CanvasEntityTransformer.RESIZE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
stroke: this.config.OUTLINE_COLOR,
anchorFill: this.config.SCALE_ANCHOR_FILL_COLOR,
anchorStroke: this.config.SCALE_ANCHOR_STROKE_COLOR,
anchorStrokeWidth: this.config.SCALE_ANCHOR_STROKE_WIDTH,
anchorSize: this.config.SCALE_ANCHOR_SIZE,
anchorCornerRadius: this.config.SCALE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
// This function is called for each anchor to style it (and do anything else you might want to do).
anchorStyleFunc: (anchor) => {
// Give the rotater special styling
if (anchor.hasName('rotater')) {
anchor.setAttrs({
height: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
width: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
cornerRadius:
CanvasEntityTransformer.ROTATE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
fill: CanvasEntityTransformer.ROTATE_ANCHOR_FILL_COLOR,
stroke: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
offsetX: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
offsetY: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
});
}
// Add some padding to the hit area of the anchors
anchor.hitFunc((context) => {
context.beginPath();
context.rect(
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
anchor.width() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2,
anchor.height() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2
);
context.closePath();
context.fillStrokeShape(anchor);
});
},
anchorDragBoundFunc: (oldPos: Coordinate, newPos: Coordinate) => {
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
// to the nearest pixel.
// If we are rotating, no need to do anything - just let the rotation happen.
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
return newPos;
}
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
// before returning them.
const stageScale = this.manager.stage.getScale();
const stagePos = this.manager.stage.getPosition();
// Unscale and round the target position to the nearest pixel.
const targetX = Math.round(newPos.x / stageScale);
const targetY = Math.round(newPos.y / stageScale);
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
// calculate that offset and add it back to the target position.
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
// be `stagePos.x % (stageScale * 8)`.
const scaledOffsetX = stagePos.x % stageScale;
const scaledOffsetY = stagePos.y % stageScale;
// Unscale the target position and add the offset to get the absolute position for this anchor.
const scaledTargetX = targetX * stageScale + scaledOffsetX;
const scaledTargetY = targetY * stageScale + scaledOffsetY;
this.log.trace(
{
oldPos,
newPos,
stageScale,
stagePos,
targetX,
targetY,
scaledOffsetX,
scaledOffsetY,
scaledTargetX,
scaledTargetY,
},
'Anchor drag bound'
);
return { x: scaledTargetX, y: scaledTargetY };
},
boundBoxFunc: (oldBoundBox, newBoundBox) => {
// Bail if we are not rotating, we don't need to do anything.
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
return newBoundBox;
}
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
// the nearest 45 degrees when shift is held.
if (this.manager.stateApi.$shiftKey.get()) {
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
return oldBoundBox;
}
}
return newBoundBox;
},
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
boundBoxFunc: this.boxBoundFunc,
}),
proxyRect: new Konva.Rect({
name: `${this.type}:proxy_rect`,
@@ -241,129 +212,15 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
}),
};
this.konva.transformer.on('transformstart', () => {
// Just logging in this callback. Called on mouse down of a transform anchor.
this.log.trace(
{
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
scaleX: this.konva.proxyRect.scaleX(),
scaleY: this.konva.proxyRect.scaleY(),
rotation: this.konva.proxyRect.rotation(),
},
'Transform started'
);
});
this.konva.transformer.on('transform', () => {
// This is called when a transform anchor is dragged. By this time, the transform constraints in the above
// callbacks have been enforced, and the transformer has updated its nodes' attributes. We need to pass the
// updated attributes to the object group, propagating the transformation on down.
this.syncObjectGroupWithProxyRect();
});
this.konva.transformer.on('transformend', () => {
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
// Snap the position to the nearest pixel.
const x = this.konva.proxyRect.x();
const y = this.konva.proxyRect.y();
const snappedX = Math.round(x);
const snappedY = Math.round(y);
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
// the snapped width and height.
const width = this.konva.proxyRect.width();
const height = this.konva.proxyRect.height();
const scaleX = this.konva.proxyRect.scaleX();
const scaleY = this.konva.proxyRect.scaleY();
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
// negative, we need to take the absolute value of the width and height.
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
// Update interaction rect and object group attributes.
this.konva.proxyRect.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
this.parent.renderer.konva.objectGroup.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
// Rotation is only retrieved for logging purposes.
const rotation = this.konva.proxyRect.rotation();
this.log.trace(
{
x,
y,
width,
height,
scaleX,
scaleY,
rotation,
snappedX,
snappedY,
targetWidth,
targetHeight,
snappedScaleX,
snappedScaleY,
},
'Transform ended'
);
});
this.konva.proxyRect.on('dragmove', () => {
// Snap the interaction rect to the nearest pixel
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
});
// The object group is translated by the difference between the interaction rect's new and old positions (which is
// stored as this.pixelRect)
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
});
});
this.konva.proxyRect.on('dragend', () => {
if (this.isTransforming) {
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
// re-render of the entity and bork the transformation.
return;
}
const position = {
x: this.konva.proxyRect.x() - this.pixelRect.x,
y: this.konva.proxyRect.y() - this.pixelRect.y,
};
this.log.trace({ position }, 'Position changed');
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
});
this.konva.transformer.on('transform', this.syncObjectGroupWithProxyRect);
this.konva.transformer.on('transformend', this.snapProxyRectToPixelGrid);
this.konva.proxyRect.on('dragmove', this.onDragMove);
this.konva.proxyRect.on('dragend', this.onDragEnd);
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
this.subscriptions.add(
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
this.manager.stateApi.$stageAttrs.listen((newVal, oldVal) => {
this.manager.stage.$stageAttrs.listen((newVal, oldVal) => {
if (newVal.scale !== oldVal.scale) {
this.syncScale();
}
@@ -379,7 +236,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
);
// When the selected tool changes, we need to update the transformer's interaction state.
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.syncInteractionState));
this.subscriptions.add(this.manager.tool.$tool.listen(this.syncInteractionState));
// When the selected entity changes, we need to update the transformer's interaction state.
this.subscriptions.add(this.manager.stateApi.$selectedEntityIdentifier.listen(this.syncInteractionState));
@@ -389,6 +246,182 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.parent.konva.layer.add(this.konva.transformer);
}
anchorStyleFunc = (anchor: Konva.Rect): void => {
// Give the rotater special styling
if (anchor.hasName('rotater')) {
anchor.setAttrs({
height: this.config.ROTATE_ANCHOR_SIZE,
width: this.config.ROTATE_ANCHOR_SIZE,
cornerRadius: this.config.ROTATE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
fill: this.config.ROTATE_ANCHOR_FILL_COLOR,
stroke: this.config.SCALE_ANCHOR_FILL_COLOR,
offsetX: this.config.ROTATE_ANCHOR_SIZE / 2,
offsetY: this.config.ROTATE_ANCHOR_SIZE / 2,
});
}
// Add some padding to the hit area of the anchors
anchor.hitFunc((context) => {
context.beginPath();
context.rect(
-this.config.ANCHOR_HIT_PADDING,
-this.config.ANCHOR_HIT_PADDING,
anchor.width() + this.config.ANCHOR_HIT_PADDING * 2,
anchor.height() + this.config.ANCHOR_HIT_PADDING * 2
);
context.closePath();
context.fillStrokeShape(anchor);
});
};
anchorDragBoundFunc = (oldPos: Coordinate, newPos: Coordinate) => {
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
// to the nearest pixel.
// If we are rotating, no need to do anything - just let the rotation happen.
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
return newPos;
}
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
// before returning them.
const stageScale = this.manager.stage.getScale();
const stagePos = this.manager.stage.getPosition();
// Unscale and round the target position to the nearest pixel.
const targetX = Math.round(newPos.x / stageScale);
const targetY = Math.round(newPos.y / stageScale);
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
// calculate that offset and add it back to the target position.
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
// be `stagePos.x % (stageScale * 8)`.
const scaledOffsetX = stagePos.x % stageScale;
const scaledOffsetY = stagePos.y % stageScale;
// Unscale the target position and add the offset to get the absolute position for this anchor.
const scaledTargetX = targetX * stageScale + scaledOffsetX;
const scaledTargetY = targetY * stageScale + scaledOffsetY;
return { x: scaledTargetX, y: scaledTargetY };
};
boxBoundFunc = (oldBoundBox: RectWithRotation, newBoundBox: RectWithRotation) => {
// Bail if we are not rotating, we don't need to do anything.
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
return newBoundBox;
}
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
// the nearest 45 degrees when shift is held.
if (this.manager.stateApi.$shiftKey.get()) {
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
return oldBoundBox;
}
}
return newBoundBox;
};
/**
* Snaps the proxy rect to the nearest pixel, syncing the object group with the proxy rect.
*/
snapProxyRectToPixelGrid = () => {
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
// Snap the position to the nearest pixel.
const x = this.konva.proxyRect.x();
const y = this.konva.proxyRect.y();
const snappedX = Math.round(x);
const snappedY = Math.round(y);
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
// the snapped width and height.
const width = this.konva.proxyRect.width();
const height = this.konva.proxyRect.height();
const scaleX = this.konva.proxyRect.scaleX();
const scaleY = this.konva.proxyRect.scaleY();
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
// negative, we need to take the absolute value of the width and height.
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
// Update interaction rect and object group attributes.
this.konva.proxyRect.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
this.syncObjectGroupWithProxyRect();
};
/**
* Fits the proxy rect to the bounding box of the parent entity, then syncs the object group with the proxy rect.
*/
fitProxyRectToBbox = () => {
const { rect } = this.manager.stateApi.getBbox();
const scaleX = rect.width / this.konva.proxyRect.width();
const scaleY = rect.height / this.konva.proxyRect.height();
this.konva.proxyRect.setAttrs({
x: rect.x,
y: rect.y,
scaleX,
scaleY,
rotation: 0,
});
this.syncObjectGroupWithProxyRect();
};
onDragMove = () => {
// Snap the interaction rect to the nearest pixel
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
});
// The object group is translated by the difference between the interaction rect's new and old positions (which is
// stored as this.pixelRect)
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
});
};
onDragEnd = () => {
if (this.$isTransforming.get()) {
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
// re-render of the entity and bork the transformation.
return;
}
const pixelRect = this.$pixelRect.get();
const position = {
x: this.konva.proxyRect.x() - pixelRect.x,
y: this.konva.proxyRect.y() - pixelRect.y,
};
this.log.trace({ position }, 'Position changed');
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
};
// TODO(psyche): These don't work when the entity is rotated, need to do some math to offset the flip after rotation
// flipHorizontal = () => {
// if (!this.isTransforming || this.$isProcessing.get()) {
@@ -444,7 +477,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
*/
update = (position: Coordinate, bbox: Rect) => {
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: position.x + bbox.x - bboxPadding,
@@ -468,14 +501,17 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
syncInteractionState = () => {
this.log.trace('Syncing interaction state');
if (this.isPendingRectCalculation || this.pixelRect.width === 0 || this.pixelRect.height === 0) {
const pixelRect = this.$pixelRect.get();
const isPendingRectCalculation = this.$isPendingRectCalculation.get();
if (isPendingRectCalculation || pixelRect.width === 0 || pixelRect.height === 0) {
// If the rect is being calculated, or if the rect has no width or height, we can't interact with the transformer
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
return;
}
const tool = this.manager.stateApi.$tool.get();
const tool = this.manager.tool.$tool.get();
const isSelected = this.manager.stateApi.getIsSelected(this.parent.id);
if (!this.parent.renderer.hasObjects() || this.parent.state.isLocked || !this.parent.state.isEnabled) {
@@ -485,11 +521,11 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
return;
}
if (isSelected && !this.isTransforming && tool === 'move') {
if (isSelected && !this.$isTransforming.get() && tool === 'move') {
// We are moving this layer, it must be listening
this.parent.konva.layer.listening(true);
this.setInteractionMode('drag');
} else if (isSelected && this.isTransforming) {
} else if (isSelected && this.$isTransforming.get()) {
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is
// active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected.
if (tool !== 'view') {
@@ -511,7 +547,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
*/
syncScale = () => {
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - bboxPadding,
@@ -528,16 +564,16 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
*/
startTransform = () => {
this.log.debug('Starting transform');
this.isTransforming = true;
this.manager.stateApi.$tool.set('move');
this.$isTransforming.set(true);
this.manager.tool.$tool.set('move');
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or
// interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected
// TODO(psyche): We just set the tool to 'move', why would it be 'view'? Investigate and figure out if this is needed
const shouldListen = this.manager.stateApi.$tool.get() !== 'view';
const shouldListen = this.manager.tool.$tool.get() !== 'view';
this.parent.konva.layer.listening(shouldListen);
this.setInteractionMode('all');
this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier());
this.manager.stateApi.$transformingAdapter.set(this.parent);
};
/**
@@ -547,7 +583,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.log.debug('Applying transform');
this.$isProcessing.set(true);
const rect = this.getRelativeRect();
await this.parent.renderer.rasterize({ rect, replaceObjects: true });
await this.parent.renderer.rasterize({ rect, replaceObjects: true, attrs: { filters: [] } });
this.requestRectCalculation();
this.stopTransform();
};
@@ -565,14 +601,14 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
stopTransform = () => {
this.log.debug('Stopping transform');
this.isTransforming = false;
this.$isTransforming.set(false);
this.setInteractionMode('off');
// Reset the transform of the the entity. We've either replaced the transformed objects with a rasterized image, or
// canceled a transformation. In either case, the scale should be reset.
this.resetTransform();
this.syncInteractionState();
this.manager.stateApi.$transformingEntity.set(null);
this.manager.stateApi.$transformingAdapter.set(null);
this.$isProcessing.set(false);
};
@@ -601,16 +637,17 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.log.trace('Updating position');
const position = get(arg, 'position', this.parent.state.position);
const pixelRect = this.$pixelRect.get();
const groupAttrs: Partial<GroupConfig> = {
x: position.x + this.pixelRect.x,
y: position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
x: position.x + pixelRect.x,
y: position.y + pixelRect.y,
offsetX: pixelRect.x,
offsetY: pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
this.update(position, this.pixelRect);
this.update(position, pixelRect);
};
/**
@@ -621,7 +658,7 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
* - 'off': The transformer is not interactable.
*/
setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => {
this.interactionMode = interactionMode;
this.$interactionMode.set(interactionMode);
if (interactionMode === 'drag') {
this._enableDrag();
this._disableTransform();
@@ -638,16 +675,19 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
};
updateBbox = () => {
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Updating bbox');
const nodeRect = this.$nodeRect.get();
const pixelRect = this.$pixelRect.get();
if (this.isPendingRectCalculation) {
this.log.trace({ nodeRect, pixelRect }, 'Updating bbox');
if (this.$isPendingRectCalculation.get()) {
this.syncInteractionState();
return;
}
// If the bbox has no width or height, that means the layer is fully transparent. This can happen if it is only
// eraser lines, fully clipped brush lines or if it has been fully erased.
if (this.pixelRect.width === 0 || this.pixelRect.height === 0) {
if (pixelRect.width === 0 || pixelRect.height === 0) {
// If the layer already has no objects, we don't need to reset the entity state. This would cause a push to the
// undo stack and clear the redo stack.
if (this.parent.renderer.hasObjects()) {
@@ -656,12 +696,12 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
}
} else {
this.syncInteractionState();
this.update(this.parent.state.position, this.pixelRect);
this.update(this.parent.state.position, pixelRect);
const groupAttrs: Partial<GroupConfig> = {
x: this.parent.state.position.x + this.pixelRect.x,
y: this.parent.state.position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
x: this.parent.state.position.x + pixelRect.x,
y: this.parent.state.position.y + pixelRect.y,
offsetX: pixelRect.x,
offsetY: pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
@@ -673,13 +713,13 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
calculateRect = debounce(() => {
this.log.debug('Calculating bbox');
this.isPendingRectCalculation = true;
this.$isPendingRectCalculation.set(true);
if (!this.parent.renderer.hasObjects()) {
this.log.trace('No objects, resetting bbox');
this.nodeRect = getEmptyRect();
this.pixelRect = getEmptyRect();
this.isPendingRectCalculation = false;
this.$nodeRect.set(getEmptyRect());
this.$pixelRect.set(getEmptyRect());
this.$isPendingRectCalculation.set(false);
this.updateBbox();
return;
}
@@ -687,10 +727,10 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
const rect = this.parent.renderer.konva.objectGroup.getClientRect({ skipTransform: true });
if (!this.parent.renderer.needsPixelBbox()) {
this.nodeRect = { ...rect };
this.pixelRect = { ...rect };
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Got bbox from client rect');
this.isPendingRectCalculation = false;
this.$nodeRect.set({ ...rect });
this.$pixelRect.set({ ...rect });
this.log.trace({ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get() }, 'Got bbox from client rect');
this.$isPendingRectCalculation.set(false);
this.updateBbox();
return;
}
@@ -703,26 +743,29 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
(extents) => {
if (extents) {
const { minX, minY, maxX, maxY } = extents;
this.nodeRect = { ...rect };
this.pixelRect = {
this.$nodeRect.set({ ...rect });
this.$pixelRect.set({
x: Math.round(rect.x) + minX,
y: Math.round(rect.y) + minY,
width: maxX - minX,
height: maxY - minY,
};
});
} else {
this.nodeRect = getEmptyRect();
this.pixelRect = getEmptyRect();
this.$nodeRect.set(getEmptyRect());
this.$pixelRect.set(getEmptyRect());
}
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect, extents }, `Got bbox from worker`);
this.isPendingRectCalculation = false;
this.log.trace(
{ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get(), extents },
`Got bbox from worker`
);
this.$isPendingRectCalculation.set(false);
this.updateBbox();
}
);
}, CanvasEntityTransformer.RECT_CALC_DEBOUNCE_MS);
}, this.config.RECT_CALC_DEBOUNCE_MS);
requestRectCalculation = () => {
this.isPendingRectCalculation = true;
this.$isPendingRectCalculation.set(true);
this.syncInteractionState();
this.calculateRect();
};
@@ -732,27 +775,27 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
};
_enableTransform = () => {
this.isTransformEnabled = true;
this.$isTransformEnabled.set(true);
this.konva.transformer.visible(true);
this.konva.transformer.listening(true);
this.konva.transformer.nodes([this.konva.proxyRect]);
};
_disableTransform = () => {
this.isTransformEnabled = false;
this.$isTransformEnabled.set(false);
this.konva.transformer.visible(false);
this.konva.transformer.listening(false);
this.konva.transformer.nodes([]);
};
_enableDrag = () => {
this.isDragEnabled = true;
this.$isDragEnabled.set(true);
this.konva.proxyRect.visible(true);
this.konva.proxyRect.listening(true);
};
_disableDrag = () => {
this.isDragEnabled = false;
this.$isDragEnabled.set(false);
this.konva.proxyRect.visible(false);
this.konva.proxyRect.listening(false);
};
@@ -765,32 +808,27 @@ export class CanvasEntityTransformer extends CanvasModuleABC {
this.konva.outlineRect.visible(false);
};
/**
* Gets a JSON-serializable object that describes the transformer.
*/
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
mode: this.interactionMode,
isTransformEnabled: this.isTransformEnabled,
isDragEnabled: this.isDragEnabled,
nodeRect: this.$nodeRect.get(),
pixelRect: this.$pixelRect.get(),
isPendingRectCalculation: this.$isPendingRectCalculation.get(),
isTransforming: this.$isTransforming.get(),
interactionMode: this.$interactionMode.get(),
isDragEnabled: this.$isDragEnabled.get(),
isTransformEnabled: this.$isTransformEnabled.get(),
isProcessing: this.$isProcessing.get(),
};
};
/**
* Destroys the transformer, cleaning up any subscriptions.
*/
destroy = () => {
this.log.debug('Destroying entity transformer module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.outlineRect.destroy();
this.konva.transformer.destroy();
this.konva.proxyRect.destroy();
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -0,0 +1,134 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type EraserToolPreviewConfig = {
/**
* The inner border color for the eraser tool preview.
*/
BORDER_INNER_COLOR: string;
/**
* The outer border color for the eraser tool preview.
*/
BORDER_OUTER_COLOR: string;
};
const DEFAULT_CONFIG: EraserToolPreviewConfig = {
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
};
export class CanvasEraserToolPreview extends CanvasModuleBase {
readonly type = 'eraser_tool_preview';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
config: EraserToolPreviewConfig = DEFAULT_CONFIG;
konva: {
group: Konva.Group;
cutoutCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:eraser_group`, listening: false }),
cutoutCircle: new Konva.Circle({
name: `${this.type}:eraser_cutout_circle`,
listening: false,
strokeEnabled: false,
// The fill is used only to erase what is underneath it, so its color doesn't matter - just needs to be opaque
fill: 'white',
globalCompositeOperation: 'destination-out',
}),
innerBorder: new Konva.Ring({
name: `${this.type}:eraser_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:eraser_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
};
this.konva.group.add(this.konva.cutoutCircle, this.konva.innerBorder, this.konva.outerBorder);
}
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.cutoutCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
});
// But the borders are in screen-pixels
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying eraser tool preview module');
this.konva.group.destroy();
};
}

View File

@@ -1,7 +1,7 @@
import type { SerializableObject } from 'common/types';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasEntityIdentifier, CanvasImageState, FilterConfig } from 'features/controlLayers/store/types';
import { IMAGE_FILTERS, imageDTOToImageObject } from 'features/controlLayers/store/types';
@@ -11,14 +11,14 @@ import { getImageDTO } from 'services/api/endpoints/images';
import type { BatchConfig, ImageDTO, S } from 'services/api/types';
import { assert } from 'tsafe';
export class CanvasFilterModule extends CanvasModuleABC {
export class CanvasFilterModule extends CanvasModuleBase {
readonly type = 'canvas_filter';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
imageState: CanvasImageState | null = null;
@@ -30,9 +30,10 @@ export class CanvasFilterModule extends CanvasModuleABC {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating filter module');
}
@@ -49,7 +50,7 @@ export class CanvasFilterModule extends CanvasModuleABC {
return;
}
this.$adapter.set(entity.adapter);
this.manager.stateApi.$tool.set('view');
this.manager.tool.$tool.set('view');
};
previewFilter = async () => {
@@ -167,21 +168,4 @@ export class CanvasFilterModule extends CanvasModuleABC {
return batch;
};
destroy = () => {
this.log.trace('Destroying filter module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,32 +3,36 @@ import { logger } from 'app/logging/logger';
import type { AppStore } from 'app/store/store';
import type { SerializableObject } from 'common/types';
import { SyncableMap } from 'common/util/SyncableMap/SyncableMap';
import { CanvasBboxModule } from 'features/controlLayers/konva/CanvasBboxModule';
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
import { CanvasCompositorModule } from 'features/controlLayers/konva/CanvasCompositorModule';
import { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule';
import { CanvasRenderingModule } from 'features/controlLayers/konva/CanvasRenderingModule';
import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule';
import { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
import { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { CanvasWorkerModule } from 'features/controlLayers/konva/CanvasWorkerModule.js';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type Konva from 'konva';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
import type { CanvasEntityLayerAdapter } from './CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from './CanvasEntityMaskAdapter';
import { CanvasPreviewModule } from './CanvasPreviewModule';
import { CanvasStateApiModule } from './CanvasStateApiModule';
export const $canvasManager = atom<CanvasManager | null>(null);
export class CanvasManager extends CanvasModuleABC {
export class CanvasManager extends CanvasModuleBase {
readonly type = 'manager';
id: string;
path: string[];
manager: CanvasManager;
parent: CanvasManager;
log: Logger;
store: AppStore;
@@ -52,7 +56,6 @@ export class CanvasManager extends CanvasModuleABC {
};
stateApi: CanvasStateApiModule;
preview: CanvasPreviewModule;
background: CanvasBackgroundModule;
filter: CanvasFilterModule;
stage: CanvasStageModule;
@@ -60,6 +63,14 @@ export class CanvasManager extends CanvasModuleABC {
cache: CanvasCacheModule;
renderer: CanvasRenderingModule;
compositor: CanvasCompositorModule;
tool: CanvasToolModule;
bbox: CanvasBboxModule;
stagingArea: CanvasStagingAreaModule;
progressImage: CanvasProgressImageModule;
konva: {
previewLayer: Konva.Layer;
};
_isDebugging: boolean = false;
@@ -68,6 +79,7 @@ export class CanvasManager extends CanvasModuleABC {
this.id = getPrefixedId(this.type);
this.path = [this.id];
this.manager = this;
this.parent = this;
this.log = logger('canvas').child((message) => {
return {
...message,
@@ -87,14 +99,28 @@ export class CanvasManager extends CanvasModuleABC {
this.worker = new CanvasWorkerModule(this);
this.cache = new CanvasCacheModule(this);
this.renderer = new CanvasRenderingModule(this);
this.preview = new CanvasPreviewModule(this);
this.filter = new CanvasFilterModule(this);
this.compositor = new CanvasCompositorModule(this);
this.stage.addLayer(this.preview.getLayer());
this.background = new CanvasBackgroundModule(this);
this.stage.addLayer(this.background.konva.layer);
this.konva = {
previewLayer: new Konva.Layer({ listening: false, imageSmoothingEnabled: false }),
};
this.stage.addLayer(this.konva.previewLayer);
this.tool = new CanvasToolModule(this);
this.stagingArea = new CanvasStagingAreaModule(this);
this.progressImage = new CanvasProgressImageModule(this);
this.bbox = new CanvasBboxModule(this);
// Must add in this order for correct z-index
this.konva.previewLayer.add(this.stagingArea.konva.group);
this.konva.previewLayer.add(this.progressImage.konva.group);
this.konva.previewLayer.add(this.bbox.konva.group);
this.konva.previewLayer.add(this.tool.konva.group);
}
enableDebugging() {
@@ -110,7 +136,7 @@ export class CanvasManager extends CanvasModuleABC {
this.log.debug('Initializing canvas manager module');
// These atoms require the canvas manager to be set up before we can provide their initial values
this.stateApi.$transformingEntity.set(null);
this.stateApi.$transformingAdapter.set(null);
this.stateApi.$toolState.set(this.stateApi.getToolState());
this.stateApi.$selectedEntityIdentifier.set(this.stateApi.getCanvasState().selectedEntityIdentifier);
this.stateApi.$currentFill.set(this.stateApi.getCurrentFill());
@@ -121,19 +147,28 @@ export class CanvasManager extends CanvasModuleABC {
};
destroy = () => {
this.log.debug('Destroying canvas manager module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
for (const adapter of this.adapters.getAll()) {
adapter.destroy();
}
this.bbox.destroy();
this.stagingArea.destroy();
this.tool.destroy();
this.progressImage.destroy();
this.konva.previewLayer.destroy();
this.stateApi.destroy();
this.preview.destroy();
this.background.destroy();
this.filter.destroy();
this.worker.destroy();
this.renderer.destroy();
this.compositor.destroy();
this.stage.destroy();
$canvasManager.set(null);
};
@@ -152,7 +187,10 @@ export class CanvasManager extends CanvasModuleABC {
inpaintMasks: Array.from(this.adapters.inpaintMasks.values()).map((adapter) => adapter.repr()),
regionMasks: Array.from(this.adapters.regionMasks.values()).map((adapter) => adapter.repr()),
stateApi: this.stateApi.repr(),
preview: this.preview.repr(),
bbox: this.bbox.repr(),
stagingArea: this.stagingArea.repr(),
tool: this.tool.repr(),
progressImage: this.progressImage.repr(),
background: this.background.repr(),
filter: this.filter.repr(),
worker: this.worker.repr(),
@@ -162,19 +200,19 @@ export class CanvasManager extends CanvasModuleABC {
};
};
getLoggingContext = (): SerializableObject => {
return {
path: this.path.join('.'),
};
getLoggingContext = (): SerializableObject => ({ path: this.path });
buildPath = (canvasModule: CanvasModuleBase): string[] => {
return canvasModule.parent.path.concat(canvasModule.id);
};
buildLogger = (getContext: () => SerializableObject): Logger => {
buildLogger = (canvasModule: CanvasModuleBase): Logger => {
return this.log.child((message) => {
return {
...message,
context: {
...message.context,
...getContext(),
...canvasModule.getLoggingContext(),
},
};
});

View File

@@ -1,20 +0,0 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Logger } from 'roarr';
export abstract class CanvasModuleABC {
abstract id: string;
abstract type: string;
abstract path: string[];
abstract manager: CanvasManager;
abstract log: Logger;
abstract subscriptions: Set<() => void>;
abstract getLoggingContext: () => SerializableObject;
abstract destroy: () => void;
abstract repr: () => SerializableObject & {
id: string;
path: string[];
type: string;
};
}

View File

@@ -0,0 +1,133 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Logger } from 'roarr';
export abstract class CanvasModuleBase {
/**
* The unique identifier of the module.
*
* If the module is associated with an entity, this should be the entity's id. Otherwise, the id should be based on
* the module's type. The `getPrefixedId` utility should be used for generating ids.
*
* @example
* ```ts
* this.id = getPrefixedId(this.type);
* // this.id -> "raster_layer:aS2NREsrlz"
* ```
*/
abstract id: string;
/**
* The type of the module.
*/
abstract type: string;
/**
* The path of the module in the canvas module tree.
*
* Modules should use the manager's `buildPath` method to set this value.
*
* @example
* ```ts
* this.path = this.manager.buildPath(this);
* // this.path -> ["manager:3PWJWmHbou", "raster_layer:aS2NREsrlz", "entity_renderer:sfLO4j1B0n", "brush_line:Zrsu8gpZMd"]
* ```
*/
abstract path: string[];
/**
* The canvas manager.
*/
abstract manager: CanvasManager;
/**
* The parent module. This may be the canvas manager or another module.
*/
abstract parent: CanvasModuleBase;
/**
* The logger for the module. The logger must be a `ROARR` logger.
*
* Modules should use the manager's `buildLogger` method to set this value.
*
* @example
* ```ts
* this.log = this.manager.buildLogger(this);
* ```
*/
abstract log: Logger;
/**
* Returns a logging context object that includes relevant information about the module.
* Canvas modules may override this method to include additional information in the logging context, but should
* always include the parent's logging context.
*
* The default implementation includes the parent context and the module's path.
*
* @example
* ```ts
* getLoggingContext = () => {
* return {
* ...this.parent.getLoggingContext(),
* path: this.path,
* someImportantValue: this.someImportantValue,
* };
* };
* ```
*/
getLoggingContext: () => SerializableObject = () => {
return {
...this.parent.getLoggingContext(),
path: this.path,
};
};
/**
* Cleans up the module when it is disposed.
*
* Canvas modules may override this method to clean up any loose ends. For example:
* - Destroy Konva nodes
* - Unsubscribe from any subscriptions
* - Abort async operations
* - Close websockets
* - Terminate workers
*
* This method is called when the module is disposed. For example:
* - When an entity is deleted and its module is destroyed
* - When the canvas manager is destroyed
*
* The default implementation only logs a message.
*
* @example
* ```ts
* destroy = () => {
* this.log('Destroying module');
* this.subscriptions.forEach((unsubscribe) => unsubscribe());
* this.konva.group.destroy();
* };
* ```
*/
destroy: () => void = () => {
this.log('Destroying module');
};
/**
* Returns a serializable representation of the module.
* Canvas modules may override this method to include additional information in the representation.
* The default implementation includes id, type, and path.
*
* @example
* ```ts
* repr = () => {
* return {
* id: this.id,
* type: this.type,
* path: this.path,
* state: deepClone(this.state),
* };
* };
* ```
*/
repr: () => SerializableObject = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
}

View File

@@ -2,12 +2,12 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasBrushLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
readonly type = 'object_brush_line_renderer';
id: string;
@@ -15,7 +15,6 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
parent: CanvasEntityRenderer;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasBrushLineState;
konva: {
@@ -29,10 +28,10 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating brush line renderer module');
this.log.debug({ state }, 'Creating module');
this.konva = {
group: new Konva.Group({
@@ -71,17 +70,16 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
return false;
}
destroy = () => {
this.log.debug('Destroying brush line renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
@@ -91,8 +89,4 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,12 +1,12 @@
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasEraserLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
readonly type = 'object_eraser_line_renderer';
id: string;
@@ -14,7 +14,6 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
parent: CanvasEntityRenderer;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasEraserLineState;
konva: {
@@ -24,19 +23,18 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
constructor(state: CanvasEraserLineState, parent: CanvasEntityRenderer) {
super();
const { id, clip } = state;
this.id = id;
this.id = state.id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating eraser line renderer module');
this.konva = {
group: new Konva.Group({
name: `${this.type}:group`,
clip,
clip: state.clip,
listening: false,
}),
line: new Konva.Line({
@@ -70,17 +68,16 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
return false;
}
destroy = () => {
this.log.debug('Destroying eraser line renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
@@ -90,8 +87,4 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
import { loadImage } from 'features/controlLayers/konva/util';
import type { CanvasImageState } from 'features/controlLayers/store/types';
@@ -12,7 +12,7 @@ import Konva from 'konva';
import type { Logger } from 'roarr';
import { getImageDTO } from 'services/api/endpoints/images';
export class CanvasObjectImageRenderer extends CanvasModuleABC {
export class CanvasObjectImageRenderer extends CanvasModuleBase {
readonly type = 'object_image_renderer';
id: string;
@@ -20,7 +20,6 @@ export class CanvasObjectImageRenderer extends CanvasModuleABC {
parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasImageState;
konva: {
@@ -35,15 +34,15 @@ export class CanvasObjectImageRenderer extends CanvasModuleABC {
constructor(state: CanvasImageState, parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule) {
super();
const { id, image } = state;
const { width, height } = image;
this.id = id;
this.id = state.id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating image renderer module');
this.log.debug({ state }, 'Creating module');
const { width, height } = state.image;
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
@@ -169,7 +168,6 @@ export class CanvasObjectImageRenderer extends CanvasModuleABC {
destroy = () => {
this.log.debug('Destroying image renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
@@ -189,8 +187,4 @@ export class CanvasObjectImageRenderer extends CanvasModuleABC {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -2,12 +2,12 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasRectState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectRectRenderer extends CanvasModuleABC {
export class CanvasObjectRectRenderer extends CanvasModuleBase {
readonly type = 'object_rect_renderer';
id: string;
@@ -15,7 +15,6 @@ export class CanvasObjectRectRenderer extends CanvasModuleABC {
parent: CanvasEntityRenderer;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasRectState;
konva: {
@@ -26,14 +25,13 @@ export class CanvasObjectRectRenderer extends CanvasModuleABC {
constructor(state: CanvasRectState, parent: CanvasEntityRenderer) {
super();
const { id } = state;
this.id = id;
this.id = state.id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating rect renderer module');
this.log.debug({ state }, 'Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
@@ -69,8 +67,7 @@ export class CanvasObjectRectRenderer extends CanvasModuleABC {
}
destroy = () => {
this.log.debug('Destroying rect renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.log.debug('Destroying module');
this.konva.group.destroy();
};
@@ -84,8 +81,4 @@ export class CanvasObjectRectRenderer extends CanvasModuleABC {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,81 +0,0 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
import { CanvasBboxModule } from './CanvasBboxModule';
import { CanvasStagingAreaModule } from './CanvasStagingAreaModule';
import { CanvasToolModule } from './CanvasToolModule';
export class CanvasPreviewModule extends CanvasModuleABC {
readonly type = 'preview';
id: string;
path: string[];
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
konva: {
layer: Konva.Layer;
};
tool: CanvasToolModule;
bbox: CanvasBboxModule;
stagingArea: CanvasStagingAreaModule;
progressImage: CanvasProgressImageModule;
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating preview module');
this.konva = {
layer: new Konva.Layer({ listening: false, imageSmoothingEnabled: false }),
};
this.stagingArea = new CanvasStagingAreaModule(this);
this.konva.layer.add(...this.stagingArea.getNodes());
this.progressImage = new CanvasProgressImageModule(this);
this.konva.layer.add(...this.progressImage.getNodes());
this.bbox = new CanvasBboxModule(this);
this.konva.layer.add(this.bbox.konva.group);
this.tool = new CanvasToolModule(this);
this.konva.layer.add(this.tool.konva.group);
}
getLayer = () => {
return this.konva.layer;
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.debug('Destroying preview module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.stagingArea.destroy();
this.progressImage.destroy();
this.bbox.destroy();
this.tool.destroy();
this.konva.layer.destroy();
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,26 +1,20 @@
import { Mutex } from 'async-mutex';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId, loadImage } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
import type { S } from 'services/api/types';
export class CanvasProgressImageModule extends CanvasModuleABC {
export class CanvasProgressImageModule extends CanvasModuleBase {
readonly type = 'progress_image';
id: string;
path: string[];
parent: CanvasPreviewModule;
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
/**
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
*/
subscriptions: Set<() => void> = new Set();
progressImageId: string | null = null;
konva: {
group: Konva.Group;
@@ -34,13 +28,13 @@ export class CanvasProgressImageModule extends CanvasModuleABC {
mutex: Mutex = new Mutex();
constructor(parent: CanvasPreviewModule) {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating progress image module');
@@ -106,21 +100,8 @@ export class CanvasProgressImageModule extends CanvasModuleABC {
}
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.debug('Destroying progress image module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.log.debug('Destroying module');
this.konva.group.destroy();
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,22 +1,21 @@
import type { SerializableObject } from 'common/types';
import { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasSessionState } from 'features/controlLayers/store/canvasSessionSlice';
import type { CanvasSettingsState } from 'features/controlLayers/store/canvasSettingsSlice';
import type { CanvasState } from 'features/controlLayers/store/types';
import type { Logger } from 'roarr';
export class CanvasRenderingModule extends CanvasModuleABC {
export class CanvasRenderingModule extends CanvasModuleBase {
readonly type = 'canvas_renderer';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
state: CanvasState | null = null;
settings: CanvasSettingsState | null = null;
@@ -27,10 +26,12 @@ export class CanvasRenderingModule extends CanvasModuleABC {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('canvas_renderer');
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating canvas renderer module');
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
}
render = async () => {
@@ -55,6 +56,11 @@ export class CanvasRenderingModule extends CanvasModuleABC {
const prevState = this.state;
this.state = state;
this.manager.stateApi.$toolState.set(this.manager.stateApi.getToolState());
this.manager.stateApi.$selectedEntityIdentifier.set(state.selectedEntityIdentifier);
this.manager.stateApi.$selectedEntity.set(this.manager.stateApi.getSelectedEntity());
this.manager.stateApi.$currentFill.set(this.manager.stateApi.getCurrentFill());
if (prevState === state) {
// No changes to state - no need to render
return;
@@ -66,11 +72,6 @@ export class CanvasRenderingModule extends CanvasModuleABC {
await this.renderInpaintMasks(state, prevState);
await this.renderBbox(state, prevState);
this.arrangeEntities(state, prevState);
this.manager.stateApi.$toolState.set(this.manager.stateApi.getToolState());
this.manager.stateApi.$selectedEntityIdentifier.set(state.selectedEntityIdentifier);
this.manager.stateApi.$selectedEntity.set(this.manager.stateApi.getSelectedEntity());
this.manager.stateApi.$currentFill.set(this.manager.stateApi.getCurrentFill());
};
renderSettings = () => {
@@ -109,10 +110,6 @@ export class CanvasRenderingModule extends CanvasModuleABC {
await this.renderStagingArea(session, prevSession);
};
getLoggingContext = (): SerializableObject => {
return { ...this.manager.getLoggingContext(), path: this.manager.path.join('.') };
};
renderBackground = (settings: CanvasSettingsState, prevSettings: CanvasSettingsState | null) => {
if (!prevSettings || settings.dynamicGrid !== prevSettings.dynamicGrid) {
this.manager.background.render();
@@ -247,13 +244,13 @@ export class CanvasRenderingModule extends CanvasModuleABC {
renderBbox = (state: CanvasState, prevState: CanvasState | null) => {
if (!prevState || state.bbox !== prevState.bbox) {
this.manager.preview.bbox.render();
this.manager.bbox.render();
}
};
renderStagingArea = async (session: CanvasSessionState, prevSession: CanvasSessionState | null) => {
if (!prevSession || session !== prevSession) {
await this.manager.preview.stagingArea.render();
await this.manager.stagingArea.render();
}
};
@@ -276,7 +273,7 @@ export class CanvasRenderingModule extends CanvasModuleABC {
// 3. Control layers
// 4. Regions
// 5. Inpaint masks
// 6. Preview (bbox, staging area, progress image, tool)
// 6. Preview layer (bbox, staging area, progress image, tool)
this.manager.background.konva.layer.zIndex(++zIndex);
@@ -296,20 +293,7 @@ export class CanvasRenderingModule extends CanvasModuleABC {
this.manager.adapters.inpaintMasks.get(id)?.konva.layer.zIndex(++zIndex);
}
this.manager.preview.getLayer().zIndex(++zIndex);
this.manager.konva.previewLayer.zIndex(++zIndex);
}
};
repr = () => {
return {
id: this.id,
path: this.path,
type: this.type,
};
};
destroy = () => {
this.log.debug('Destroying canvas renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
}

View File

@@ -1,36 +1,73 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CANVAS_SCALE_BY } from 'features/controlLayers/konva/constants';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId, getRectUnion } from 'features/controlLayers/konva/util';
import type { CanvasEntityIdentifier, Coordinate, Dimensions, Rect } from 'features/controlLayers/store/types';
import type {
CanvasEntityIdentifier,
Coordinate,
Dimensions,
Rect,
StageAttrs,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { clamp } from 'lodash-es';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
export class CanvasStageModule extends CanvasModuleABC {
readonly type = 'stage';
type CanvasStageModuleConfig = {
/**
* The minimum (furthest-zoomed-in) scale of the canvas
*/
MIN_SCALE: number;
/**
* The maximum (furthest-zoomed-out) scale of the canvas
*/
MAX_SCALE: number;
/**
* The factor by which the canvas should be scaled when zooming in/out
*/
SCALE_FACTOR: number;
};
static MIN_CANVAS_SCALE = 0.1;
static MAX_CANVAS_SCALE = 20;
const DEFAULT_CONFIG: CanvasStageModuleConfig = {
MIN_SCALE: 0.1,
MAX_SCALE: 20,
SCALE_FACTOR: 0.999,
};
export class CanvasStageModule extends CanvasModuleBase {
readonly type = 'stage';
id: string;
path: string[];
konva: { stage: Konva.Stage };
parent: CanvasManager;
manager: CanvasManager;
container: HTMLDivElement;
log: Logger;
container: HTMLDivElement;
konva: { stage: Konva.Stage };
config: CanvasStageModuleConfig = DEFAULT_CONFIG;
$stageAttrs = atom<StageAttrs>({
x: 0,
y: 0,
width: 0,
height: 0,
scale: 0,
});
subscriptions = new Set<() => void>();
constructor(stage: Konva.Stage, container: HTMLDivElement, manager: CanvasManager) {
super();
this.id = getPrefixedId('stage');
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating stage module');
this.log.debug('Creating module');
this.container = container;
this.konva = { stage };
@@ -67,7 +104,7 @@ export class CanvasStageModule extends CanvasModuleABC {
this.log.trace('Fitting stage to container');
this.konva.stage.width(this.konva.stage.container().offsetWidth);
this.konva.stage.height(this.konva.stage.container().offsetHeight);
this.manager.stateApi.$stageAttrs.set({
this.$stageAttrs.set({
x: this.konva.stage.x(),
y: this.konva.stage.y(),
width: this.konva.stage.width(),
@@ -127,8 +164,8 @@ export class CanvasStageModule extends CanvasModuleABC {
scaleY: scale,
});
this.manager.stateApi.$stageAttrs.set({
...this.manager.stateApi.$stageAttrs.get(),
this.$stageAttrs.set({
...this.$stageAttrs.get(),
x,
y,
scale,
@@ -163,11 +200,7 @@ export class CanvasStageModule extends CanvasModuleABC {
*/
setScale = (scale: number, center: Coordinate = this.getCenter(true)) => {
this.log.trace('Setting scale');
const newScale = clamp(
Math.round(scale * 100) / 100,
CanvasStageModule.MIN_CANVAS_SCALE,
CanvasStageModule.MAX_CANVAS_SCALE
);
const newScale = clamp(Math.round(scale * 100) / 100, this.config.MIN_SCALE, this.config.MAX_SCALE);
const { x, y } = this.getPosition();
const oldScale = this.getScale();
@@ -185,7 +218,7 @@ export class CanvasStageModule extends CanvasModuleABC {
scaleY: newScale,
});
this.manager.stateApi.$stageAttrs.set({
this.$stageAttrs.set({
x: Math.floor(this.konva.stage.x()),
y: Math.floor(this.konva.stage.y()),
width: this.konva.stage.width(),
@@ -207,7 +240,7 @@ export class CanvasStageModule extends CanvasModuleABC {
if (cursorPos) {
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY;
const scale = this.manager.stage.getScale() * CANVAS_SCALE_BY ** delta;
const scale = this.manager.stage.getScale() * this.config.SCALE_FACTOR ** delta;
this.manager.stage.setScale(scale, cursorPos);
}
};
@@ -217,7 +250,7 @@ export class CanvasStageModule extends CanvasModuleABC {
return;
}
this.manager.stateApi.$stageAttrs.set({
this.$stageAttrs.set({
// Stage position should always be an integer, else we get fractional pixels which are blurry
x: Math.floor(this.konva.stage.x()),
y: Math.floor(this.konva.stage.y()),
@@ -232,7 +265,7 @@ export class CanvasStageModule extends CanvasModuleABC {
return;
}
this.manager.stateApi.$stageAttrs.set({
this.$stageAttrs.set({
// Stage position should always be an integer, else we get fractional pixels which are blurry
x: Math.floor(this.konva.stage.x()),
y: Math.floor(this.konva.stage.y()),
@@ -282,21 +315,9 @@ export class CanvasStageModule extends CanvasModuleABC {
this.konva.stage.add(layer);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.debug('Destroying stage module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.stage.destroy();
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,54 +1,50 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectImageRenderer } from 'features/controlLayers/konva/CanvasObjectImageRenderer';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { imageDTOToImageWithDims, type StagingAreaImage } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
export class CanvasStagingAreaModule extends CanvasModuleABC {
export class CanvasStagingAreaModule extends CanvasModuleBase {
readonly type = 'staging_area';
id: string;
path: string[];
parent: CanvasPreviewModule;
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions: Set<() => void> = new Set();
konva: { group: Konva.Group };
image: CanvasObjectImageRenderer | null;
selectedImage: StagingAreaImage | null;
/**
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
*/
subscriptions: Set<() => void> = new Set();
$shouldShowStagedImage = atom<boolean>(true);
constructor(parent: CanvasPreviewModule) {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating staging area module');
this.log.debug('Creating module');
this.konva = { group: new Konva.Group({ name: `${this.type}:group`, listening: false }) };
this.image = null;
this.selectedImage = null;
this.subscriptions.add(this.manager.stateApi.$shouldShowStagedImage.listen(this.render));
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
}
render = async () => {
this.log.trace('Rendering staging area');
const session = this.manager.stateApi.getSession();
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this.manager.stateApi.$shouldShowStagedImage.get();
const shouldShowStagedImage = this.$shouldShowStagedImage.get();
this.selectedImage = session.stagedImages[session.selectedStagedImageIndex] ?? null;
this.konva.group.position({ x, y });
@@ -88,7 +84,7 @@ export class CanvasStagingAreaModule extends CanvasModuleABC {
};
destroy = () => {
this.log.debug('Destroying staging area module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
if (this.image) {
this.image.destroy();
@@ -106,8 +102,4 @@ export class CanvasStagingAreaModule extends CanvasModuleABC {
selectedImage: this.selectedImage,
};
};
getLoggingContext = (): SerializableObject => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,7 +3,7 @@ import type { AppStore } from 'app/store/store';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
bboxChanged,
@@ -13,7 +13,6 @@ import {
entityRasterized,
entityRectAdded,
entityReset,
entitySelected,
} from 'features/controlLayers/store/canvasSlice';
import { selectAllRenderableEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors';
import {
@@ -28,7 +27,6 @@ import type {
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
Coordinate,
EntityBrushLineAddedPayload,
EntityEraserLineAddedPayload,
EntityIdentifierPayload,
@@ -37,13 +35,10 @@ import type {
EntityRectAddedPayload,
Rect,
RgbaColor,
RgbColor,
StageAttrs,
Tool,
} from 'features/controlLayers/store/types';
import { RGBA_BLACK } from 'features/controlLayers/store/types';
import type { WritableAtom } from 'nanostores';
import { atom } from 'nanostores';
import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig } from 'services/api/types';
@@ -75,66 +70,115 @@ type EntityStateAndAdapter =
adapter: CanvasEntityMaskAdapter;
};
export class CanvasStateApiModule extends CanvasModuleABC {
export class CanvasStateApiModule extends CanvasModuleBase {
readonly type = 'state_api';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
/**
* The redux store.
*/
store: AppStore;
constructor(store: AppStore, manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating state api module');
this.store = store;
}
// Reminder - use arrow functions to avoid binding issues
/**
* Gets the canvas slice.
*
* The state is stored in redux.
*/
getCanvasState = () => {
return selectCanvasSlice(this.store.getState());
};
/**
* Resets an entity, pushing state to redux.
*/
resetEntity = (arg: EntityIdentifierPayload) => {
this.store.dispatch(entityReset(arg));
};
/**
* Updates an entity's position, pushing state to redux.
*/
setEntityPosition = (arg: EntityMovedPayload) => {
this.store.dispatch(entityMoved(arg));
};
/**
* Adds a brush line to an entity, pushing state to redux.
*/
addBrushLine = (arg: EntityBrushLineAddedPayload) => {
this.store.dispatch(entityBrushLineAdded(arg));
};
/**
* Adds an eraser line to an entity, pushing state to redux.
*/
addEraserLine = (arg: EntityEraserLineAddedPayload) => {
this.store.dispatch(entityEraserLineAdded(arg));
};
/**
* Adds a rectangle to an entity, pushing state to redux.
*/
addRect = (arg: EntityRectAddedPayload) => {
this.store.dispatch(entityRectAdded(arg));
};
/**
* Rasterizes an entity, pushing state to redux.
*/
rasterizeEntity = (arg: EntityRasterizedPayload) => {
this.store.dispatch(entityRasterized(arg));
};
setSelectedEntity = (arg: EntityIdentifierPayload) => {
this.store.dispatch(entitySelected(arg));
};
setGenerationBbox = (bbox: Rect) => {
this.store.dispatch(bboxChanged(bbox));
/**
* Sets the generation bbox rect, pushing state to redux.
*/
setGenerationBbox = (rect: Rect) => {
this.store.dispatch(bboxChanged(rect));
};
/**
* Sets the brush width, pushing state to redux.
*/
setBrushWidth = (width: number) => {
this.store.dispatch(brushWidthChanged(width));
};
/**
* Sets the eraser width, pushing state to redux.
*/
setEraserWidth = (width: number) => {
this.store.dispatch(eraserWidthChanged(width));
};
/**
* Sets the fill color, pushing state to redux.
*/
setFill = (fill: RgbaColor) => {
return this.store.dispatch(fillChanged(fill));
};
/**
* Enqueues a batch, pushing state to redux.
*/
enqueueBatch = (batch: BatchConfig) => {
this.store.dispatch(
queueApi.endpoints.enqueueBatch.initiate(batch, {
@@ -142,35 +186,76 @@ export class CanvasStateApiModule extends CanvasModuleABC {
})
);
};
/**
* Gets the generation bbox state from redux.
*/
getBbox = () => {
return this.getCanvasState().bbox;
};
/**
* Gets the tool state from redux.
*/
getToolState = () => {
return this.store.getState().tool;
};
/**
* Gets the canvas settings from redux.
*/
getSettings = () => {
return this.store.getState().canvasSettings;
};
/**
* Gets the regions state from redux.
*/
getRegionsState = () => {
return this.getCanvasState().regions;
};
/**
* Gets the raster layers state from redux.
*/
getRasterLayersState = () => {
return this.getCanvasState().rasterLayers;
};
/**
* Gets the control layers state from redux.
*/
getControlLayersState = () => {
return this.getCanvasState().controlLayers;
};
/**
* Gets the inpaint masks state from redux.
*/
getInpaintMasksState = () => {
return this.getCanvasState().inpaintMasks;
};
/**
* Gets the canvas session state from redux.
*/
getSession = () => {
return this.store.getState().canvasSession;
};
getIsSelected = (id: string) => {
/**
* Checks if an entity is selected.
*/
getIsSelected = (id: string): boolean => {
return this.getCanvasState().selectedEntityIdentifier?.id === id;
};
/**
* Gets an entity by its identifier. The entity's state is retrieved from the redux store, and its adapter is
* retrieved from the canvas manager.
*
* Both state and adapter must exist for the entity to be returned.
*/
getEntity(identifier: CanvasEntityIdentifier): EntityStateAndAdapter | null {
const state = this.getCanvasState();
@@ -203,6 +288,9 @@ export class CanvasStateApiModule extends CanvasModuleABC {
return null;
}
/**
* Gets the number of entities that are currently rendered on the canvas.
*/
getRenderedEntityCount = () => {
const renderableEntities = selectAllRenderableEntities(this.getCanvasState());
let count = 0;
@@ -214,6 +302,10 @@ export class CanvasStateApiModule extends CanvasModuleABC {
return count;
};
/**
* Gets the currently selected entity, if any. The entity's state is retrieved from the redux store, and its adapter
* is retrieved from the canvas manager.
*/
getSelectedEntity = () => {
const state = this.getCanvasState();
if (state.selectedEntityIdentifier) {
@@ -222,6 +314,16 @@ export class CanvasStateApiModule extends CanvasModuleABC {
return null;
};
/**
* Gets the current fill color. The fill color is determined by the tool state and the selected entity.
*
* The fill color is determined by the tool state, except when the selected entity is a regional guidance or inpaint
* mask. In that case, the fill color is always black.
*
* Regional guidance and inpaint mask entities use a compositing rect to draw with their selected color and texture,
* so the fill color for lines and rects doesn't matter - it is never seen. The only requirement is that it is opaque.
* For consistency with conventional black and white mask images, we use black as the fill color for these entities.
*/
getCurrentFill = () => {
let currentFill: RgbaColor = this.getToolState().fill;
const selectedEntity = this.getSelectedEntity();
@@ -234,62 +336,86 @@ export class CanvasStateApiModule extends CanvasModuleABC {
return currentFill;
};
/**
* Gets the brush preview fill color. The brush preview fill color is determined by the tool state and the selected
* entity.
*
* The color is the tool state's fill color, except when the selected entity is a regional guidance or inpaint mask.
*
* These entities have their own fill color and texture, so the brush preview should use those instead of the tool
* state's fill color.
*/
getBrushPreviewFill = (): RgbaColor => {
const selectedEntity = this.getSelectedEntity();
if (selectedEntity?.state.type === 'regional_guidance' || selectedEntity?.state.type === 'inpaint_mask') {
// The brush should use the mask opacity for these enktity types
return { ...selectedEntity.state.fill.color, a: 1 };
// TODO(psyche): If we move the brush preview's Konva nodes to the selected entity renderer, we can draw them
// under the entity's compositing rect, so they would use selected entity's selected color and texture. As a
// temporary workaround to improve the UX when using a brush on a regional guidance or inpaint mask, we use the
// selected entity's fill color with 50% opacity.
return { ...selectedEntity.state.fill.color, a: 0.5 };
} else {
return this.getToolState().fill;
}
};
$transformingEntity = atom<CanvasEntityIdentifier | null>(null);
$isProcessingTransform = atom<boolean>(false);
/**
* The entity adapter being transformed, if any.
*/
$transformingAdapter = atom<CanvasEntityLayerAdapter | CanvasEntityMaskAdapter | null>(null);
/**
* Whether an entity is currently being transformed. Derived from `$transformingAdapter`.
*/
$isTranforming = computed(this.$transformingAdapter, (transformingAdapter) => Boolean(transformingAdapter));
/**
* A nanostores atom, kept in sync with the redux store's tool state.
*/
$toolState: WritableAtom<ToolState> = atom();
/**
* The current fill color, derived from the tool state and the selected entity.
*/
$currentFill: WritableAtom<RgbaColor> = atom();
/**
* The currently selected entity, if any. Includes the entity latest state and its adapter.
*/
$selectedEntity: WritableAtom<EntityStateAndAdapter | null> = atom();
/**
* The currently selected entity's identifier, if an entity is selected.
*/
$selectedEntityIdentifier: WritableAtom<CanvasEntityIdentifier | null> = atom();
$colorUnderCursor: WritableAtom<RgbColor> = atom(RGBA_BLACK);
// Read-write state, ephemeral interaction state
$tool = atom<Tool>('brush');
$toolBuffer = atom<Tool | null>(null);
$isDrawing = atom<boolean>(false);
$isMouseDown = atom<boolean>(false);
$lastAddedPoint = atom<Coordinate | null>(null);
$lastMouseDownPos = atom<Coordinate | null>(null);
$lastCursorPos = atom<Coordinate | null>(null);
/**
* The last canvas progress event. This is set in a global event listener. The staging area may set it to null when it
* consumes the event.
*/
$lastCanvasProgressEvent = $lastCanvasProgressEvent;
/**
* Whether the space key is currently pressed.
*/
$spaceKey = atom<boolean>(false);
/**
* Whether the alt key is currently pressed.
*/
$altKey = $alt;
/**
* Whether the ctrl key is currently pressed.
*/
$ctrlKey = $ctrl;
/**
* Whether the meta key is currently pressed.
*/
$metaKey = $meta;
/**
* Whether the shift key is currently pressed.
*/
$shiftKey = $shift;
$shouldShowStagedImage = atom(true);
$stageAttrs = atom<StageAttrs>({
x: 0,
y: 0,
width: 0,
height: 0,
scale: 0,
});
destroy = () => {
this.log.debug('Destroying state api module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,12 +1,8 @@
import { rgbaColorToString, rgbColorToString } from 'common/util/colorCodeTransformers';
import { CanvasBrushToolPreview } from 'features/controlLayers/konva/CanvasBrushToolPreview';
import { CanvasColorPickerToolPreview } from 'features/controlLayers/konva/CanvasColorPickerToolPreview';
import { CanvasEraserToolPreview } from 'features/controlLayers/konva/CanvasEraserToolPreview';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import {
BRUSH_BORDER_INNER_COLOR,
BRUSH_BORDER_OUTER_COLOR,
BRUSH_SPACING_TARGET_SCALE,
} from 'features/controlLayers/konva/constants';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import {
alignCoordForTool,
calculateNewBrushSizeFromWheelDelta,
@@ -26,218 +22,108 @@ import type {
RgbColor,
Tool,
} from 'features/controlLayers/store/types';
import { isDrawableEntity } from 'features/controlLayers/store/types';
import { isDrawableEntity, RGBA_BLACK } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
export class CanvasToolModule extends CanvasModuleABC {
type CanvasToolModuleConfig = {
BRUSH_SPACING_TARGET_SCALE: number;
};
const DEFAULT_CONFIG: CanvasToolModuleConfig = {
BRUSH_SPACING_TARGET_SCALE: 0.1,
};
export class CanvasToolModule extends CanvasModuleBase {
readonly type = 'tool';
static readonly COLOR_PICKER_RADIUS = 25;
static readonly COLOR_PICKER_THICKNESS = 15;
static readonly COLOR_PICKER_CROSSHAIR_SPACE = 5;
static readonly COLOR_PICKER_CROSSHAIR_INNER_THICKNESS = 1.5;
static readonly COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS = 3;
static readonly COLOR_PICKER_CROSSHAIR_SIZE = 10;
id: string;
path: string[];
parent: CanvasPreviewModule;
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions: Set<() => void> = new Set();
config: CanvasToolModuleConfig = DEFAULT_CONFIG;
brushToolPreview: CanvasBrushToolPreview;
eraserToolPreview: CanvasEraserToolPreview;
colorPickerToolPreview: CanvasColorPickerToolPreview;
/**
* The currently selected tool.
*/
$tool = atom<Tool>('brush');
/**
* A buffer for the currently selected tool. This is used to temporarily store the tool while the user is using any
* hold-to-activate tools, like the view or color picker tools.
*/
$toolBuffer = atom<Tool | null>(null);
/**
* The last point added to the current entity.
*/
$lastAddedPoint = atom<Coordinate | null>(null);
/**
* Whether the mouse is currently down.
*/
$isMouseDown = atom<boolean>(false);
/**
* The last position where the mouse was down.
*/
$lastMouseDownPos = atom<Coordinate | null>(null);
/**
* The last cursor position.
*/
$lastCursorPos = atom<Coordinate | null>(null);
/**
* The color currently under the cursor. Only has a value when the color picker tool is active.
*/
$colorUnderCursor = atom<RgbColor>(RGBA_BLACK);
konva: {
stage: Konva.Stage;
group: Konva.Group;
brush: {
group: Konva.Group;
fillCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
eraser: {
group: Konva.Group;
fillCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
colorPicker: {
group: Konva.Group;
newColor: Konva.Ring;
oldColor: Konva.Arc;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
crosshairNorthInner: Konva.Line;
crosshairNorthOuter: Konva.Line;
crosshairEastInner: Konva.Line;
crosshairEastOuter: Konva.Line;
crosshairSouthInner: Konva.Line;
crosshairSouthOuter: Konva.Line;
crosshairWestInner: Konva.Line;
crosshairWestOuter: Konva.Line;
};
};
/**
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
*/
subscriptions: Set<() => void> = new Set();
constructor(parent: CanvasPreviewModule) {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating tool module');
this.brushToolPreview = new CanvasBrushToolPreview(this);
this.eraserToolPreview = new CanvasEraserToolPreview(this);
this.colorPickerToolPreview = new CanvasColorPickerToolPreview(this);
this.konva = {
stage: this.manager.stage.konva.stage,
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
brush: {
group: new Konva.Group({ name: `${this.type}:brush_group`, listening: false }),
fillCircle: new Konva.Circle({
name: `${this.type}:brush_fill_circle`,
listening: false,
strokeEnabled: false,
}),
innerBorder: new Konva.Ring({
name: `${this.type}:brush_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:brush_outer_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
},
eraser: {
group: new Konva.Group({ name: `${this.type}:eraser_group`, listening: false }),
fillCircle: new Konva.Circle({
name: `${this.type}:eraser_fill_circle`,
listening: false,
strokeEnabled: false,
fill: 'white',
globalCompositeOperation: 'destination-out',
}),
innerBorder: new Konva.Ring({
name: `${this.type}:eraser_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:eraser_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
},
colorPicker: {
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
newColor: new Konva.Ring({
name: `${this.type}:color_picker_new_color_ring`,
innerRadius: 0,
outerRadius: 0,
strokeEnabled: false,
}),
oldColor: new Konva.Arc({
name: `${this.type}:color_picker_old_color_arc`,
innerRadius: 0,
outerRadius: 0,
angle: 180,
strokeEnabled: false,
}),
innerBorder: new Konva.Ring({
name: `${this.type}:color_picker_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:color_picker_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
crosshairNorthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairNorthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairEastInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairEastOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairSouthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairSouthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairWestInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairWestOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
},
};
this.konva.brush.group.add(this.konva.brush.fillCircle, this.konva.brush.innerBorder, this.konva.brush.outerBorder);
this.konva.group.add(this.konva.brush.group);
this.konva.eraser.group.add(
this.konva.eraser.fillCircle,
this.konva.eraser.innerBorder,
this.konva.eraser.outerBorder
this.konva.group.add(this.brushToolPreview.konva.group);
this.konva.group.add(this.eraserToolPreview.konva.group);
this.konva.group.add(this.colorPickerToolPreview.konva.group);
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
this.subscriptions.add(
this.manager.stateApi.$toolState.listen((value, oldValue) => {
if (
value !== oldValue ||
value.brush.width !== oldValue.brush.width ||
value.eraser.width !== oldValue.eraser.width ||
value.fill !== oldValue.fill
) {
this.render();
}
})
);
this.konva.group.add(this.konva.eraser.group);
this.konva.colorPicker.group.add(
this.konva.colorPicker.newColor,
this.konva.colorPicker.oldColor,
this.konva.colorPicker.innerBorder,
this.konva.colorPicker.outerBorder,
this.konva.colorPicker.crosshairNorthOuter,
this.konva.colorPicker.crosshairNorthInner,
this.konva.colorPicker.crosshairEastOuter,
this.konva.colorPicker.crosshairEastInner,
this.konva.colorPicker.crosshairSouthOuter,
this.konva.colorPicker.crosshairSouthInner,
this.konva.colorPicker.crosshairWestOuter,
this.konva.colorPicker.crosshairWestInner
);
this.konva.group.add(this.konva.colorPicker.group);
this.subscriptions.add(this.manager.stateApi.$stageAttrs.listen(this.render));
this.subscriptions.add(this.manager.stateApi.$toolState.listen(this.render));
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render));
this.subscriptions.add(this.$tool.listen(this.render));
const cleanupListeners = this.setEventListeners();
@@ -245,17 +131,17 @@ export class CanvasToolModule extends CanvasModuleABC {
}
setToolVisibility = (tool: Tool, isDrawable: boolean) => {
this.konva.brush.group.visible(isDrawable && tool === 'brush');
this.konva.eraser.group.visible(isDrawable && tool === 'eraser');
this.konva.colorPicker.group.visible(tool === 'colorPicker');
this.brushToolPreview.setVisibility(isDrawable && tool === 'brush');
this.eraserToolPreview.setVisibility(isDrawable && tool === 'eraser');
this.colorPickerToolPreview.setVisibility(tool === 'colorPicker');
};
syncCursorStyle = () => {
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const isMouseDown = this.manager.stateApi.$isMouseDown.get();
const tool = this.manager.stateApi.$tool.get();
const isMouseDown = this.$isMouseDown.get();
const tool = this.$tool.get();
const isDrawable =
!!selectedEntity &&
@@ -264,7 +150,7 @@ export class CanvasToolModule extends CanvasModuleABC {
isDrawableEntity(selectedEntity.state);
// Update the stage's pointer style
if (Boolean(this.manager.stateApi.$transformingEntity.get()) || renderedEntityCount === 0) {
if (this.manager.stateApi.$isTranforming.get() || renderedEntityCount === 0) {
// We are transforming and/or have no layers, so we should not render any tool
stage.container.style.cursor = 'default';
} else if (tool === 'view') {
@@ -294,148 +180,12 @@ export class CanvasToolModule extends CanvasModuleABC {
}
};
renderBrushTool = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.brush.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
});
// But the borders are in screen-pixels
this.konva.brush.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.brush.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
renderEraserTool = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.eraser.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: 'white',
});
// But the borders are in screen-pixels
this.konva.eraser.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.eraser.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
renderColorPicker = (cursorPos: Coordinate) => {
const toolState = this.manager.stateApi.getToolState();
const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_RADIUS + CanvasToolModule.COLOR_PICKER_THICKNESS
);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.colorPicker.newColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.oldColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.colorPicker.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SIZE);
const space = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SPACE);
const innerThickness = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_INNER_THICKNESS);
const outerThickness = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS);
this.konva.colorPicker.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.colorPicker.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
};
render = () => {
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
const tool = this.manager.stateApi.$tool.get();
const cursorPos = this.$lastCursorPos.get();
const tool = this.$tool.get();
const isDrawable =
!!selectedEntity &&
@@ -455,11 +205,11 @@ export class CanvasToolModule extends CanvasModuleABC {
// No need to render the brush preview if the cursor position or color is missing
if (cursorPos && tool === 'brush') {
this.renderBrushTool(cursorPos);
this.brushToolPreview.render();
} else if (cursorPos && tool === 'eraser') {
this.renderEraserTool(cursorPos);
this.eraserToolPreview.render();
} else if (cursorPos && tool === 'colorPicker') {
this.renderColorPicker(cursorPos);
this.colorPickerToolPreview.render();
}
this.setToolVisibility(tool, isDrawable);
@@ -468,10 +218,7 @@ export class CanvasToolModule extends CanvasModuleABC {
syncLastCursorPos = (): Coordinate | null => {
const pos = getScaledCursorPosition(this.konva.stage);
if (!pos) {
return null;
}
this.manager.stateApi.$lastCursorPos.set(pos);
this.$lastCursorPos.set(pos);
return pos;
};
@@ -552,16 +299,16 @@ export class CanvasToolModule extends CanvasModuleABC {
};
onStageMouseDown = async (e: KonvaEventObject<MouseEvent>) => {
this.manager.stateApi.$isMouseDown.set(true);
this.$isMouseDown.set(true);
const toolState = this.manager.stateApi.getToolState();
const tool = this.manager.stateApi.$tool.get();
const tool = this.$tool.get();
const pos = this.syncLastCursorPos();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
if (tool === 'colorPicker') {
const color = this.getColorUnderCursor();
if (color) {
this.manager.stateApi.$colorUnderCursor.set(color);
this.$colorUnderCursor.set(color);
}
if (color) {
this.manager.stateApi.setFill({ ...toolState.fill, ...color });
@@ -570,7 +317,7 @@ export class CanvasToolModule extends CanvasModuleABC {
} else {
const isDrawable = selectedEntity?.state.isEnabled && !selectedEntity.state.isLocked;
if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) {
this.manager.stateApi.$lastMouseDownPos.set(pos);
this.$lastMouseDownPos.set(pos);
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
if (tool === 'brush') {
@@ -609,7 +356,7 @@ export class CanvasToolModule extends CanvasModuleABC {
clip: this.getClip(selectedEntity.state),
});
}
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
if (tool === 'eraser') {
@@ -645,7 +392,7 @@ export class CanvasToolModule extends CanvasModuleABC {
clip: this.getClip(selectedEntity.state),
});
}
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
if (tool === 'rect') {
@@ -664,11 +411,11 @@ export class CanvasToolModule extends CanvasModuleABC {
};
onStageMouseUp = (_: KonvaEventObject<MouseEvent>) => {
this.manager.stateApi.$isMouseDown.set(false);
const pos = this.manager.stateApi.$lastCursorPos.get();
this.$isMouseDown.set(false);
const pos = this.$lastCursorPos.get();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const isDrawable = selectedEntity?.state.isEnabled && !selectedEntity.state.isLocked;
const tool = this.manager.stateApi.$tool.get();
const tool = this.$tool.get();
if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get()) {
if (tool === 'brush') {
@@ -698,7 +445,7 @@ export class CanvasToolModule extends CanvasModuleABC {
}
}
this.manager.stateApi.$lastMouseDownPos.set(null);
this.$lastMouseDownPos.set(null);
}
this.render();
};
@@ -707,12 +454,12 @@ export class CanvasToolModule extends CanvasModuleABC {
const toolState = this.manager.stateApi.getToolState();
const pos = this.syncLastCursorPos();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const tool = this.manager.stateApi.$tool.get();
const tool = this.$tool.get();
if (tool === 'colorPicker') {
const color = this.getColorUnderCursor();
if (color) {
this.manager.stateApi.$colorUnderCursor.set(color);
this.$colorUnderCursor.set(color);
}
} else {
const isDrawable = selectedEntity?.state.isEnabled && !selectedEntity.state.isLocked;
@@ -722,7 +469,7 @@ export class CanvasToolModule extends CanvasModuleABC {
if (drawingBuffer) {
if (drawingBuffer.type === 'brush_line') {
const lastPoint = getLastPointOfLine(drawingBuffer.points);
const minDistance = toolState.brush.width * BRUSH_SPACING_TARGET_SCALE;
const minDistance = toolState.brush.width * this.config.BRUSH_SPACING_TARGET_SCALE;
if (lastPoint && validateCandidatePoint(pos, lastPoint, minDistance)) {
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
@@ -730,7 +477,7 @@ export class CanvasToolModule extends CanvasModuleABC {
if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) {
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
}
} else {
@@ -750,7 +497,7 @@ export class CanvasToolModule extends CanvasModuleABC {
color: this.manager.stateApi.getCurrentFill(),
clip: this.getClip(selectedEntity.state),
});
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
}
@@ -759,7 +506,7 @@ export class CanvasToolModule extends CanvasModuleABC {
if (drawingBuffer) {
if (drawingBuffer.type === 'eraser_line') {
const lastPoint = getLastPointOfLine(drawingBuffer.points);
const minDistance = toolState.eraser.width * BRUSH_SPACING_TARGET_SCALE;
const minDistance = toolState.eraser.width * this.config.BRUSH_SPACING_TARGET_SCALE;
if (lastPoint && validateCandidatePoint(pos, lastPoint, minDistance)) {
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
@@ -767,7 +514,7 @@ export class CanvasToolModule extends CanvasModuleABC {
if (lastPoint.x !== alignedPoint.x || lastPoint.y !== alignedPoint.y) {
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
}
} else {
@@ -786,7 +533,7 @@ export class CanvasToolModule extends CanvasModuleABC {
strokeWidth: toolState.eraser.width,
clip: this.getClip(selectedEntity.state),
});
this.manager.stateApi.$lastAddedPoint.set(alignedPoint);
this.$lastAddedPoint.set(alignedPoint);
}
}
@@ -811,12 +558,12 @@ export class CanvasToolModule extends CanvasModuleABC {
onStageMouseLeave = async (e: KonvaEventObject<MouseEvent>) => {
const pos = this.syncLastCursorPos();
this.manager.stateApi.$lastCursorPos.set(null);
this.manager.stateApi.$lastMouseDownPos.set(null);
this.$lastCursorPos.set(null);
this.$lastMouseDownPos.set(null);
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const toolState = this.manager.stateApi.getToolState();
const isDrawable = selectedEntity?.state.isEnabled && !selectedEntity.state.isLocked;
const tool = this.manager.stateApi.$tool.get();
const tool = this.$tool.get();
if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) {
const drawingBuffer = selectedEntity.adapter.renderer.bufferState;
@@ -850,7 +597,7 @@ export class CanvasToolModule extends CanvasModuleABC {
}
const toolState = this.manager.stateApi.getToolState();
const tool = this.manager.stateApi.$tool.get();
const tool = this.$tool.get();
let delta = e.evt.deltaY;
@@ -880,19 +627,19 @@ export class CanvasToolModule extends CanvasModuleABC {
const selectedEntity = this.manager.stateApi.getSelectedEntity();
if (selectedEntity) {
selectedEntity.adapter.renderer.clearBuffer();
this.manager.stateApi.$lastMouseDownPos.set(null);
this.$lastMouseDownPos.set(null);
}
} else if (e.key === ' ') {
// Select the view tool on space key down
this.manager.stateApi.$toolBuffer.set(this.manager.stateApi.$tool.get());
this.manager.stateApi.$tool.set('view');
this.$toolBuffer.set(this.$tool.get());
this.$tool.set('view');
this.manager.stateApi.$spaceKey.set(true);
this.manager.stateApi.$lastCursorPos.set(null);
this.manager.stateApi.$lastMouseDownPos.set(null);
this.$lastCursorPos.set(null);
this.$lastMouseDownPos.set(null);
} else if (e.key === 'Alt') {
// Select the color picker on alt key down
this.manager.stateApi.$toolBuffer.set(this.manager.stateApi.$tool.get());
this.manager.stateApi.$tool.set('colorPicker');
this.$toolBuffer.set(this.$tool.get());
this.$tool.set('colorPicker');
}
};
@@ -905,33 +652,21 @@ export class CanvasToolModule extends CanvasModuleABC {
}
if (e.key === ' ') {
// Revert the tool to the previous tool on space key up
const toolBuffer = this.manager.stateApi.$toolBuffer.get();
this.manager.stateApi.$tool.set(toolBuffer ?? 'move');
this.manager.stateApi.$toolBuffer.set(null);
const toolBuffer = this.$toolBuffer.get();
this.$tool.set(toolBuffer ?? 'move');
this.$toolBuffer.set(null);
this.manager.stateApi.$spaceKey.set(false);
} else if (e.key === 'Alt') {
// Revert the tool to the previous tool on alt key up
const toolBuffer = this.manager.stateApi.$toolBuffer.get();
this.manager.stateApi.$tool.set(toolBuffer ?? 'move');
this.manager.stateApi.$toolBuffer.set(null);
const toolBuffer = this.$toolBuffer.get();
this.$tool.set(toolBuffer ?? 'move');
this.$toolBuffer.set(null);
}
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.debug('Destroying tool module');
this.log.debug('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,29 +1,30 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { Extents, ExtentsResult, GetBboxTask, WorkerLogMessage } from 'features/controlLayers/konva/worker';
import type { Logger } from 'roarr';
export class CanvasWorkerModule extends CanvasModuleABC {
export class CanvasWorkerModule extends CanvasModuleBase {
readonly type = 'worker';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
worker: Worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'worker' });
tasks: Map<string, { task: GetBboxTask; onComplete: (extents: Extents | null) => void }> = new Map();
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('worker');
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating worker module');
this.log.debug('Creating module');
this.worker.onmessage = (event: MessageEvent<ExtentsResult | WorkerLogMessage>) => {
const { type, data } = event.data;
@@ -71,12 +72,7 @@ export class CanvasWorkerModule extends CanvasModuleABC {
destroy = () => {
this.log.trace('Destroying worker module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.worker.terminate();
this.tasks.clear();
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,41 +1,6 @@
/**
* A transparency checker pattern image.
* This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
* This is ./transparent_bg.png as a dataURL
*/
export const TRANSPARENCY_CHECKER_PATTERN =
export const TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAEsmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjIwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMjAiCiAgIGV4aWY6Q29sb3JTcGFjZT0iMSIKICAgdGlmZjpJbWFnZVdpZHRoPSIyMCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMjAiCiAgIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiCiAgIHRpZmY6WFJlc29sdXRpb249IjMwMC8xIgogICB0aWZmOllSZXNvbHV0aW9uPSIzMDAvMSIKICAgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIKICAgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIgogICB4bXA6TW9kaWZ5RGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCIKICAgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyNC0wNC0yM1QwODoyMDo0NysxMDowMCI+CiAgIDx4bXBNTTpIaXN0b3J5PgogICAgPHJkZjpTZXE+CiAgICAgPHJkZjpsaQogICAgICBzdEV2dDphY3Rpb249InByb2R1Y2VkIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZmZpbml0eSBQaG90byAxLjEwLjgiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDQtMjNUMDg6MjA6NDcrMTA6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/Pn9pdVgAAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWR3yuDURjHP5uJmKghFy6WxpVpqMWNMgm1tGbKr5vt3S+1d3t73y3JrXKrKHHj1wV/AbfKtVJESq53TdywXs9rakv2nJ7zfM73nOfpnOeAPZJRVMPhAzWb18NTAffC4pK7oYiDTjpw4YgqhjYeCgWpaR8P2Kx457Vq1T73rzXHE4YCtkbhMUXT88LTwsG1vGbxrnC7ko7Ghc+F+3W5oPC9pcfKXLQ4VeYvi/VIeALsbcLuVBXHqlhJ66qwvByPmikov/exXuJMZOfnJPaId2MQZooAbmaYZAI/g4zK7MfLEAOyoka+7yd/lpzkKjJrrKOzSoo0efpFLUj1hMSk6AkZGdat/v/tq5EcHipXdwag/sU033qhYQdK26b5eWyapROoe4arbCU/dwQj76JvVzTPIbRuwsV1RYvtweUWdD1pUT36I9WJ25NJeD2DlkVw3ULTcrlnv/ucPkJkQ77qBvYPoE/Ot658AxagZ8FoS/a7AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAL0lEQVQ4jWM8ffo0A25gYmKCR5YJjxxBMKp5ZGhm/P//Px7pM2fO0MrmUc0jQzMAB2EIhZC3pUYAAAAASUVORK5CYII=';
/**
* The inner border color for the brush preview.
*/
export const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
/**
* The outer border color for the brush preview.
*/
export const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
/**
* The target spacing of individual points of brush strokes, as a percentage of the brush size.
*/
export const BRUSH_SPACING_TARGET_SCALE = 0.1;
/**
* Konva wheel zoom exponential scale factor
*/
export const CANVAS_SCALE_BY = 0.999;
/**
* Minimum (furthest-zoomed-out) scale
*/
export const MIN_CANVAS_SCALE = 0.1;
/**
* Maximum (furthest-zoomed-in) scale
*/
export const MAX_CANVAS_SCALE = 20;
/**
* The fine grid size of the canvas
*/
export const CANVAS_GRID_SIZE_FINE = 8;

View File

@@ -287,6 +287,11 @@ export const dataURLToImageData = (dataURL: string, width: number, height: numbe
resolve(ctx.getImageData(0, 0, width, height));
};
image.onerror = function (e) {
canvas.remove();
reject(e);
};
image.src = dataURL;
});
};

View File

@@ -42,7 +42,6 @@ import type {
CLIPVisionModelV2,
ControlModeV2,
ControlNetConfig,
Dimensions,
EntityBrushLineAddedPayload,
EntityEraserLineAddedPayload,
EntityIdentifierPayload,
@@ -85,6 +84,7 @@ const getRGMaskFill = (state: CanvasState): RgbColor => {
const initialState: CanvasState = {
_version: 3,
selectedEntityIdentifier: null,
bookmarkedEntityIdentifier: null,
rasterLayers: {
isHidden: false,
entities: [],
@@ -669,8 +669,17 @@ export const canvasSlice = createSlice({
state.selectedEntityIdentifier = { type: 'inpaint_mask', id: data.id };
},
//#region BBox
bboxScaledSizeChanged: (state, action: PayloadAction<Partial<Dimensions>>) => {
state.bbox.scaledSize = { ...state.bbox.scaledSize, ...action.payload };
bboxScaledWidthChanged: (state, action: PayloadAction<number>) => {
state.bbox.scaledSize.width = action.payload;
if (state.bbox.aspectRatio.isLocked) {
state.bbox.scaledSize.height = roundToMultiple(state.bbox.scaledSize.width / state.bbox.aspectRatio.value, 8);
}
},
bboxScaledHeightChanged: (state, action: PayloadAction<number>) => {
state.bbox.scaledSize.height = action.payload;
if (state.bbox.aspectRatio.isLocked) {
state.bbox.scaledSize.width = roundToMultiple(state.bbox.scaledSize.height * state.bbox.aspectRatio.value, 8);
}
},
bboxScaleMethodChanged: (state, action: PayloadAction<BoundingBoxScaleMethod>) => {
state.bbox.scaleMethod = action.payload;
@@ -721,6 +730,7 @@ export const canvasSlice = createSlice({
},
bboxAspectRatioLockToggled: (state) => {
state.bbox.aspectRatio.isLocked = !state.bbox.aspectRatio.isLocked;
syncScaledSize(state);
},
bboxAspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => {
const { id } = action.payload;
@@ -775,8 +785,26 @@ export const canvasSlice = createSlice({
//#region Shared entity
entitySelected: (state, action: PayloadAction<EntityIdentifierPayload>) => {
const { entityIdentifier } = action.payload;
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
// Cannot select a non-existent entity
return;
}
state.selectedEntityIdentifier = entityIdentifier;
},
bookmarkedEntityChanged: (state, action: PayloadAction<{ entityIdentifier: CanvasEntityIdentifier | null }>) => {
const { entityIdentifier } = action.payload;
if (!entityIdentifier) {
state.bookmarkedEntityIdentifier = null;
return;
}
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
// Cannot select a non-existent entity
return;
}
state.bookmarkedEntityIdentifier = entityIdentifier;
},
entityNameChanged: (state, action: PayloadAction<EntityIdentifierPayload<{ name: string | null }>>) => {
const { entityIdentifier, name } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -1091,6 +1119,7 @@ export const {
canvasClearHistory,
// All entities
entitySelected,
bookmarkedEntityChanged,
entityNameChanged,
entityReset,
entityIsEnabledToggled,
@@ -1113,7 +1142,8 @@ export const {
allEntitiesOfTypeIsHiddenToggled,
// bbox
bboxChanged,
bboxScaledSizeChanged,
bboxScaledWidthChanged,
bboxScaledHeightChanged,
bboxScaleMethodChanged,
bboxWidthChanged,
bboxHeightChanged,
@@ -1175,7 +1205,7 @@ export const canvasPersistConfig: PersistConfig<CanvasState> = {
};
const syncScaledSize = (state: CanvasState) => {
if (state.bbox.scaleMethod === 'auto') {
if (state.bbox.scaleMethod === 'auto' || (state.bbox.scaleMethod === 'manual' && state.bbox.aspectRatio.isLocked)) {
const { width, height } = state.bbox.rect;
state.bbox.scaledSize = getScaledBoundingBoxDimensions({ width, height }, state.bbox.optimalDimension);
}

View File

@@ -1,16 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import {
type CanvasControlLayerState,
type CanvasEntityIdentifier,
type CanvasEntityState,
type CanvasInpaintMaskState,
type CanvasRasterLayerState,
type CanvasRegionalGuidanceState,
type CanvasState,
isDrawableEntityType,
import type {
CanvasControlLayerState,
CanvasEntityIdentifier,
CanvasEntityState,
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
CanvasState,
} from 'features/controlLayers/store/types';
import { isDrawableEntityType } from 'features/controlLayers/store/types';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { assert } from 'tsafe';
@@ -181,6 +181,11 @@ export const selectSelectedEntityIdentifier = createSelector(
(canvas) => canvas.selectedEntityIdentifier
);
export const selectBookmarkedEntityIdentifier = createSelector(
selectCanvasSlice,
(canvas) => canvas.bookmarkedEntityIdentifier
);
export const selectIsSelectedEntityDrawable = createSelector(
selectSelectedEntityIdentifier,
(selectedEntityIdentifier) => {

View File

@@ -478,6 +478,11 @@ const zRect = z.object({
});
export type Rect = z.infer<typeof zRect>;
const zRectWithRotation = zRect.extend({
rotation: z.number(),
});
export type RectWithRotation = z.infer<typeof zRectWithRotation>;
const zCanvasBrushLineState = z.object({
id: zId,
type: z.literal('brush_line'),
@@ -688,6 +693,7 @@ export type StagingAreaImage = {
export type CanvasState = {
_version: 3;
selectedEntityIdentifier: CanvasEntityIdentifier | null;
bookmarkedEntityIdentifier: CanvasEntityIdentifier | null;
inpaintMasks: {
isHidden: boolean;
entities: CanvasInpaintMaskState[];

View File

@@ -1,7 +1,8 @@
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import { CANVAS_GRID_SIZE_FINE } from 'features/controlLayers/konva/constants';
import type { Dimensions } from 'features/controlLayers/store/types';
const CANVAS_GRID_SIZE_FINE = 8;
/**
* Scales the bounding box dimensions to the optimal dimension. The optimal dimensions should be the trained dimension
* for the model. For example, 1024 for SDXL or 512 for SD1.5.

View File

@@ -2,7 +2,7 @@ import { Box, Flex, Image } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { preventDefault } from 'common/util/stopPropagation';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import type { Dimensions } from 'features/controlLayers/store/types';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import { selectComparisonFit } from 'features/gallery/store/gallerySelectors';
@@ -79,7 +79,7 @@ export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDi
left={0}
right={0}
bottom={0}
backgroundImage={TRANSPARENCY_CHECKER_PATTERN}
backgroundImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
backgroundRepeat="repeat"
opacity={0.2}
/>

View File

@@ -1,7 +1,7 @@
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { preventDefault } from 'common/util/stopPropagation';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import type { Dimensions } from 'features/controlLayers/store/types';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import { selectComparisonFit } from 'features/gallery/store/gallerySelectors';
@@ -121,7 +121,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerD
left={0}
right={0}
bottom={0}
backgroundImage={TRANSPARENCY_CHECKER_PATTERN}
backgroundImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
backgroundRepeat="repeat"
opacity={0.2}
/>

View File

@@ -1,7 +1,7 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { bboxScaledSizeChanged } from 'features/controlLayers/store/canvasSlice';
import { bboxScaledHeightChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { selectConfigSlice } from 'features/system/store/configSlice';
import { memo, useCallback } from 'react';
@@ -24,7 +24,7 @@ const ParamScaledHeight = () => {
const onChange = useCallback(
(height: number) => {
dispatch(bboxScaledSizeChanged({ height }));
dispatch(bboxScaledHeightChanged(height));
},
[dispatch]
);

View File

@@ -1,7 +1,7 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { bboxScaledSizeChanged } from 'features/controlLayers/store/canvasSlice';
import { bboxScaledWidthChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { selectConfigSlice } from 'features/system/store/configSlice';
import { memo, useCallback } from 'react';
@@ -23,7 +23,7 @@ const ParamScaledWidth = () => {
const config = useAppSelector(selectScaledBoundingBoxWidthConfig);
const onChange = useCallback(
(width: number) => {
dispatch(bboxScaledSizeChanged({ width }));
dispatch(bboxScaledWidthChanged(width));
},
[dispatch]
);

View File

@@ -2,7 +2,7 @@ import { Box, Flex } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { CanvasEditor } from 'features/controlLayers/components/CanvasEditor';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { useIsImageViewerOpen } from 'features/gallery/components/ImageViewer/useImageViewer';

View File

@@ -5,7 +5,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { CanvasPanelContent } from 'features/controlLayers/components/CanvasPanelContent';
import { selectIsSDXL } from 'features/controlLayers/store/paramsSlice';
import { selectEntityCount } from 'features/controlLayers/store/selectors';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
@@ -18,7 +17,7 @@ import { StylePresetMenuTrigger } from 'features/stylePresets/components/StylePr
import { $isMenuOpen } from 'features/stylePresets/store/isMenuOpen';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const overlayScrollbarsStyles: CSSProperties = {
@@ -41,13 +40,6 @@ const selectedStyles: ChakraProps['sx'] = {
const ParametersPanelTextToImage = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const controlLayersCount = useAppSelector(selectEntityCount);
const controlLayersTitle = useMemo(() => {
if (controlLayersCount === 0) {
return t('controlLayers.controlLayers');
}
return `${t('controlLayers.controlLayers')} (${controlLayersCount})`;
}, [controlLayersCount, t]);
const isSDXL = useAppSelector(selectIsSDXL);
const onChangeTabs = useCallback(
(i: number) => {
@@ -95,7 +87,7 @@ const ParametersPanelTextToImage = () => {
_selected={selectedStyles}
data-testid="generation-tab-control-layers-tab-button"
>
{controlLayersTitle}
{t('controlLayers.layer_other')}
</Tab>
</TabList>
<TabPanels w="full" h="full">

View File

@@ -1 +1 @@
__version__ = "4.2.9.dev9"
__version__ = "4.2.9.dev11"