mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-17 01:47:59 -05:00
Compare commits
36 Commits
v4.2.9.dev
...
v4.2.9.dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42e34f7a7 | ||
|
|
6ac8478e2b | ||
|
|
ee04a85006 | ||
|
|
d2b521aa50 | ||
|
|
f434b487ee | ||
|
|
3b04f9d596 | ||
|
|
e96639bb97 | ||
|
|
694e9f66bb | ||
|
|
56fc0395e2 | ||
|
|
c412af52ae | ||
|
|
61cec8c482 | ||
|
|
e5924ed72d | ||
|
|
84b4bf0a7c | ||
|
|
0982dc3ca1 | ||
|
|
5b1bec3989 | ||
|
|
b0860c7249 | ||
|
|
e9df412c70 | ||
|
|
84640a0d51 | ||
|
|
61b0c49e28 | ||
|
|
7df76ae45b | ||
|
|
f1d6dcf8d5 | ||
|
|
df819c146b | ||
|
|
a1404b0e5d | ||
|
|
189481286b | ||
|
|
0c58d3cfec | ||
|
|
604dab8384 | ||
|
|
0fae29d501 | ||
|
|
cd9a33453e | ||
|
|
bb2796d9a2 | ||
|
|
072bd6c373 | ||
|
|
055a912889 | ||
|
|
a43e44fd85 | ||
|
|
a41a2737be | ||
|
|
38b545305b | ||
|
|
d34335213e | ||
|
|
b021e59c15 |
@@ -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": {
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -48,7 +48,6 @@ export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig
|
||||
return defaultControlAdapter;
|
||||
};
|
||||
|
||||
/** @knipignore */
|
||||
export const useDefaultIPAdapter = (): IPAdapterConfig => {
|
||||
const [modelConfigs] = useIPAdapterModels();
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.2.9.dev10"
|
||||
__version__ = "4.2.9.dev11"
|
||||
|
||||
Reference in New Issue
Block a user