From 56850757a9108c5fb684289abc686a9c5dad5b72 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 09:00:58 +1000
Subject: [PATCH 1/9] fix(ui): restore OG aspect ratio preview for non-t2i tabs
---
invokeai/frontend/web/package.json | 1 +
invokeai/frontend/web/pnpm-lock.yaml | 3 +
...eview.tsx => AspectRatioCanvasPreview.tsx} | 4 +-
.../ImageSize/AspectRatioIconPreview.tsx | 75 +++++++++++++++++++
.../components/ImageSize/ImageSize.tsx | 6 +-
.../components/ImageSize/constants.ts | 24 +++++-
.../ImageSizeCanvas.tsx | 2 +
.../ImageSizeLinear.tsx | 5 ++
8 files changed, 114 insertions(+), 6 deletions(-)
rename invokeai/frontend/web/src/features/parameters/components/ImageSize/{AspectRatioPreview.tsx => AspectRatioCanvasPreview.tsx} (72%)
create mode 100644 invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioIconPreview.tsx
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index b7e954035f9..a591e654a7b 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -51,6 +51,7 @@
}
},
"dependencies": {
+ "@chakra-ui/react-use-size": "^2.1.0",
"@dagrejs/dagre": "^1.1.1",
"@dagrejs/graphlib": "^2.2.1",
"@dnd-kit/core": "^6.1.0",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index fb2cad51a9a..c0cbc59ad26 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -8,6 +8,9 @@ dependencies:
'@chakra-ui/react':
specifier: ^2.8.2
version: 2.8.2(@emotion/react@11.11.3)(@emotion/styled@11.11.0)(@types/react@18.2.59)(framer-motion@11.0.6)(react-dom@18.2.0)(react@18.2.0)
+ '@chakra-ui/react-use-size':
+ specifier: ^2.1.0
+ version: 2.1.0(react@18.2.0)
'@dagrejs/dagre':
specifier: ^1.1.1
version: 1.1.1
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioPreview.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
similarity index 72%
rename from invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioPreview.tsx
rename to invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
index 4825d73bb5e..6584cb14c98 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioPreview.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioCanvasPreview.tsx
@@ -2,7 +2,7 @@ import { Flex } from '@invoke-ai/ui-library';
import { StageComponent } from 'features/regionalPrompts/components/StageComponent';
import { memo } from 'react';
-export const AspectRatioPreview = memo(() => {
+export const AspectRatioCanvasPreview = memo(() => {
return (
@@ -10,4 +10,4 @@ export const AspectRatioPreview = memo(() => {
);
});
-AspectRatioPreview.displayName = 'AspectRatioPreview';
+AspectRatioCanvasPreview.displayName = 'AspectRatioCanvasPreview';
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioIconPreview.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioIconPreview.tsx
new file mode 100644
index 00000000000..3ed7d0d8028
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/AspectRatioIconPreview.tsx
@@ -0,0 +1,75 @@
+import { useSize } from '@chakra-ui/react-use-size';
+import { Flex, Icon } from '@invoke-ai/ui-library';
+import { useImageSizeContext } from 'features/parameters/components/ImageSize/ImageSizeContext';
+import { AnimatePresence, motion } from 'framer-motion';
+import { memo, useMemo, useRef } from 'react';
+import { PiFrameCorners } from 'react-icons/pi';
+
+import {
+ BOX_SIZE_CSS_CALC,
+ ICON_CONTAINER_STYLES,
+ ICON_HIGH_CUTOFF,
+ ICON_LOW_CUTOFF,
+ MOTION_ICON_ANIMATE,
+ MOTION_ICON_EXIT,
+ MOTION_ICON_INITIAL,
+} from './constants';
+
+export const AspectRatioIconPreview = memo(() => {
+ const ctx = useImageSizeContext();
+ const containerRef = useRef(null);
+ const containerSize = useSize(containerRef);
+
+ const shouldShowIcon = useMemo(
+ () => ctx.aspectRatioState.value < ICON_HIGH_CUTOFF && ctx.aspectRatioState.value > ICON_LOW_CUTOFF,
+ [ctx.aspectRatioState.value]
+ );
+
+ const { width, height } = useMemo(() => {
+ if (!containerSize) {
+ return { width: 0, height: 0 };
+ }
+
+ let width = ctx.width;
+ let height = ctx.height;
+
+ if (ctx.width > ctx.height) {
+ width = containerSize.width;
+ height = width / ctx.aspectRatioState.value;
+ } else {
+ height = containerSize.height;
+ width = height * ctx.aspectRatioState.value;
+ }
+
+ return { width, height };
+ }, [containerSize, ctx.width, ctx.height, ctx.aspectRatioState.value]);
+
+ return (
+
+
+
+ {shouldShowIcon && (
+
+
+
+ )}
+
+
+
+ );
+});
+
+AspectRatioIconPreview.displayName = 'AspectRatioIconPreview';
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/ImageSize.tsx b/invokeai/frontend/web/src/features/parameters/components/ImageSize/ImageSize.tsx
index 811578263ae..0c702c77f15 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/ImageSize.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/ImageSize.tsx
@@ -1,6 +1,5 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
-import { AspectRatioPreview } from 'features/parameters/components/ImageSize/AspectRatioPreview';
import { AspectRatioSelect } from 'features/parameters/components/ImageSize/AspectRatioSelect';
import type { ImageSizeContextInnerValue } from 'features/parameters/components/ImageSize/ImageSizeContext';
import { ImageSizeContext } from 'features/parameters/components/ImageSize/ImageSizeContext';
@@ -13,10 +12,11 @@ import { memo } from 'react';
type ImageSizeProps = ImageSizeContextInnerValue & {
widthComponent: ReactNode;
heightComponent: ReactNode;
+ previewComponent: ReactNode;
};
export const ImageSize = memo((props: ImageSizeProps) => {
- const { widthComponent, heightComponent, ...ctx } = props;
+ const { widthComponent, heightComponent, previewComponent, ...ctx } = props;
return (
@@ -33,7 +33,7 @@ export const ImageSize = memo((props: ImageSizeProps) => {
-
+ {previewComponent}
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts b/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
index 8ecdf2bc1bb..8c8213737c7 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
@@ -1,7 +1,29 @@
import type { ComboboxOption } from '@invoke-ai/ui-library';
import type { AspectRatioID, AspectRatioState } from './types';
-
+// When the aspect ratio is between these two values, we show the icon (experimentally determined)
+export const ICON_LOW_CUTOFF = 0.23;
+export const ICON_HIGH_CUTOFF = 1 / ICON_LOW_CUTOFF;
+export const ICON_SIZE_PX = 64;
+export const ICON_PADDING_PX = 16;
+export const BOX_SIZE_CSS_CALC = `min(${ICON_SIZE_PX}px, calc(100% - ${ICON_PADDING_PX}px))`;
+export const MOTION_ICON_INITIAL = {
+ opacity: 0,
+};
+export const MOTION_ICON_ANIMATE = {
+ opacity: 1,
+ transition: { duration: 0.1 },
+};
+export const MOTION_ICON_EXIT = {
+ opacity: 0,
+ transition: { duration: 0.1 },
+};
+export const ICON_CONTAINER_STYLES = {
+ width: '100%',
+ height: '100%',
+ alignItems: 'center',
+ justifyContent: 'center',
+};
export const ASPECT_RATIO_OPTIONS: ComboboxOption[] = [
{ label: 'Free' as const, value: 'Free' },
{ label: '16:9' as const, value: '16:9' },
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeCanvas.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeCanvas.tsx
index eaf7b257305..878174fe755 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeCanvas.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeCanvas.tsx
@@ -2,6 +2,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { aspectRatioChanged, setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
import ParamBoundingBoxHeight from 'features/parameters/components/Canvas/BoundingBox/ParamBoundingBoxHeight';
import ParamBoundingBoxWidth from 'features/parameters/components/Canvas/BoundingBox/ParamBoundingBoxWidth';
+import { AspectRatioIconPreview } from 'features/parameters/components/ImageSize/AspectRatioIconPreview';
import { ImageSize } from 'features/parameters/components/ImageSize/ImageSize';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
@@ -41,6 +42,7 @@ export const ImageSizeCanvas = memo(() => {
aspectRatioState={aspectRatioState}
heightComponent={}
widthComponent={}
+ previewComponent={}
onChangeAspectRatioState={onChangeAspectRatioState}
onChangeWidth={onChangeWidth}
onChangeHeight={onChangeHeight}
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
index 9d5d2eb2841..498faf452b4 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ImageSettingsAccordion/ImageSizeLinear.tsx
@@ -1,13 +1,17 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { ParamHeight } from 'features/parameters/components/Core/ParamHeight';
import { ParamWidth } from 'features/parameters/components/Core/ParamWidth';
+import { AspectRatioCanvasPreview } from 'features/parameters/components/ImageSize/AspectRatioCanvasPreview';
+import { AspectRatioIconPreview } from 'features/parameters/components/ImageSize/AspectRatioIconPreview';
import { ImageSize } from 'features/parameters/components/ImageSize/ImageSize';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import { aspectRatioChanged, heightChanged, widthChanged } from 'features/parameters/store/generationSlice';
+import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
export const ImageSizeLinear = memo(() => {
const dispatch = useAppDispatch();
+ const tab = useAppSelector(activeTabNameSelector);
const width = useAppSelector((s) => s.generation.width);
const height = useAppSelector((s) => s.generation.height);
const aspectRatioState = useAppSelector((s) => s.generation.aspectRatio);
@@ -40,6 +44,7 @@ export const ImageSizeLinear = memo(() => {
aspectRatioState={aspectRatioState}
heightComponent={}
widthComponent={}
+ previewComponent={tab === 'txt2img' ? : }
onChangeAspectRatioState={onChangeAspectRatioState}
onChangeWidth={onChangeWidth}
onChangeHeight={onChangeHeight}
From 2a1f9d76493c2cc8e66ed46ad6fb084dfb50f74b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 09:01:48 +1000
Subject: [PATCH 2/9] feat(ui): regional control defaults to having a positive
prompt
---
.../src/features/regionalPrompts/store/regionalPromptsSlice.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
index 30130484707..0e18f2c83dd 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
@@ -109,7 +109,7 @@ export const regionalPromptsSlice = createSlice({
y: 0,
autoNegative: 'invert',
needsPixelBbox: false,
- positivePrompt: null,
+ positivePrompt: '',
negativePrompt: null,
ipAdapterIds: [],
};
From 4ee2363cc32b43064faa3a94d3cb685335ad2141 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 09:02:57 +1000
Subject: [PATCH 3/9] feat(ui): hide add prompt buttons when user has a prompt
---
.../src/features/regionalPrompts/components/RPLayerListItem.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx
index 67f2897fade..ef98a659ac2 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RPLayerListItem.tsx
@@ -75,7 +75,7 @@ export const RPLayerListItem = memo(({ layerId }: Props) => {
-
+ {!hasPositivePrompt && !hasNegativePrompt && !hasIPAdapters && }
{hasPositivePrompt && }
{hasNegativePrompt && }
{hasIPAdapters && }
From f0d8c562089b144871be9e112f23bd0b10818ca1 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 10:47:52 +1000
Subject: [PATCH 4/9] chore(ui): lint
---
.../src/features/parameters/components/ImageSize/constants.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts b/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
index 8c8213737c7..0e435e795ef 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
+++ b/invokeai/frontend/web/src/features/parameters/components/ImageSize/constants.ts
@@ -4,8 +4,8 @@ import type { AspectRatioID, AspectRatioState } from './types';
// When the aspect ratio is between these two values, we show the icon (experimentally determined)
export const ICON_LOW_CUTOFF = 0.23;
export const ICON_HIGH_CUTOFF = 1 / ICON_LOW_CUTOFF;
-export const ICON_SIZE_PX = 64;
-export const ICON_PADDING_PX = 16;
+const ICON_SIZE_PX = 64;
+const ICON_PADDING_PX = 16;
export const BOX_SIZE_CSS_CALC = `min(${ICON_SIZE_PX}px, calc(100% - ${ICON_PADDING_PX}px))`;
export const MOTION_ICON_INITIAL = {
opacity: 0,
From b8da62525c316e47c7b7f8d95736169bbd234833 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 10:49:01 +1000
Subject: [PATCH 5/9] tidy(ui): clean up renderer functions
- Split logic to create layers/objects from the updating logic
- Organize and comment functions
---
.../components/StageComponent.tsx | 17 +-
.../store/regionalPromptsSlice.ts | 4 +-
.../regionalPrompts/util/getLayerBlobs.ts | 2 +-
.../regionalPrompts/util/renderers.ts | 518 ++++++++++--------
4 files changed, 308 insertions(+), 233 deletions(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
index ee32a12bbbe..97fe0a5c533 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
@@ -6,6 +6,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks';
import {
$cursorPosition,
+ $isMouseOver,
$lastMouseDownPos,
$tool,
isVectorMaskLayer,
@@ -14,7 +15,7 @@ import {
layerTranslated,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
-import { renderers } from 'features/regionalPrompts/util/renderers';
+import { debouncedRenderers, renderers } from 'features/regionalPrompts/util/renderers';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import type { MutableRefObject } from 'react';
@@ -49,16 +50,20 @@ const useStageRenderer = (
const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave, onMouseWheel } = useMouseEvents();
const cursorPosition = useStore($cursorPosition);
const lastMouseDownPos = useStore($lastMouseDownPos);
+ const isMouseOver = useStore($isMouseOver);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
- const renderLayers = useMemo(() => (asPreview ? renderers.layersDebounced : renderers.layers), [asPreview]);
+ const renderLayers = useMemo(
+ () => (asPreview ? debouncedRenderers.renderLayers : renderers.renderLayers),
+ [asPreview]
+ );
const renderToolPreview = useMemo(
- () => (asPreview ? renderers.toolPreviewDebounced : renderers.toolPreview),
+ () => (asPreview ? debouncedRenderers.renderToolPreview : renderers.renderToolPreview),
[asPreview]
);
- const renderBbox = useMemo(() => (asPreview ? renderers.bboxDebounced : renderers.bbox), [asPreview]);
+ const renderBbox = useMemo(() => (asPreview ? debouncedRenderers.renderBbox : renderers.renderBbox), [asPreview]);
const renderBackground = useMemo(
- () => (asPreview ? renderers.backgroundDebounced : renderers.background),
+ () => (asPreview ? debouncedRenderers.renderBackground : renderers.renderBackground),
[asPreview]
);
@@ -158,6 +163,7 @@ const useStageRenderer = (
state.globalMaskLayerOpacity,
cursorPosition,
lastMouseDownPos,
+ isMouseOver,
state.brushSize
);
}, [
@@ -168,6 +174,7 @@ const useStageRenderer = (
state.globalMaskLayerOpacity,
cursorPosition,
lastMouseDownPos,
+ isMouseOver,
state.brushSize,
renderToolPreview,
]);
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
index 0e18f2c83dd..1d32938868a 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
@@ -16,7 +16,7 @@ type DrawingTool = 'brush' | 'eraser';
export type Tool = DrawingTool | 'move' | 'rect';
-type VectorMaskLine = {
+export type VectorMaskLine = {
id: string;
type: 'vector_mask_line';
tool: DrawingTool;
@@ -24,7 +24,7 @@ type VectorMaskLine = {
points: number[];
};
-type VectorMaskRect = {
+export type VectorMaskRect = {
id: string;
type: 'vector_mask_rect';
x: number;
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts
index 183042bb43b..28a11b649d6 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/util/getLayerBlobs.ts
@@ -20,7 +20,7 @@ export const getRegionalPromptLayerBlobs = async (
const reduxLayers = state.regionalPrompts.present.layers;
const container = document.createElement('div');
const stage = new Konva.Stage({ container, width: state.generation.width, height: state.generation.height });
- renderers.layers(stage, reduxLayers, 1, 'brush');
+ renderers.renderLayers(stage, reduxLayers, 1, 'brush');
const konvaLayers = stage.find(`.${VECTOR_MASK_LAYER_NAME}`);
const blobs: Record = {};
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
index 80bb1ddad5f..76c9bb4f93b 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
@@ -1,9 +1,14 @@
import { getStore } from 'app/store/nanostores/store';
import { rgbaColorToString, rgbColorToString } from 'features/canvas/util/colorToString';
import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks';
-import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
+import type {
+ Layer,
+ Tool,
+ VectorMaskLayer,
+ VectorMaskLine,
+ VectorMaskRect,
+} from 'features/regionalPrompts/store/regionalPromptsSlice';
import {
- $isMouseOver,
$tool,
BACKGROUND_LAYER_ID,
BACKGROUND_RECT_ID,
@@ -35,6 +40,7 @@ const BBOX_NOT_SELECTED_STROKE = 'rgba(255, 255, 255, 0.353)';
const BBOX_NOT_SELECTED_MOUSEOVER_STROKE = 'rgba(255, 255, 255, 0.661)';
const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
+// This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
const STAGE_BG_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=';
@@ -51,6 +57,68 @@ const selectVectorMaskObjects = (node: Konva.Node) => {
return node.name() === VECTOR_MASK_LAYER_LINE_NAME || node.name() === VECTOR_MASK_LAYER_RECT_NAME;
};
+/**
+ * Creates the brush preview layer.
+ * @param stage The konva stage to render on.
+ * @returns The brush preview layer.
+ */
+const createToolPreviewLayer = (stage: Konva.Stage) => {
+ // Initialize the brush preview layer & add to the stage
+ const toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: false, listening: false });
+ stage.add(toolPreviewLayer);
+
+ // Add handlers to show/hide the brush preview layer
+ stage.on('mousemove', (e) => {
+ const tool = $tool.get();
+ e.target
+ .getStage()
+ ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
+ ?.visible(tool === 'brush' || tool === 'eraser');
+ });
+ stage.on('mouseleave', (e) => {
+ e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
+ });
+ stage.on('mouseenter', (e) => {
+ const tool = $tool.get();
+ e.target
+ .getStage()
+ ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
+ ?.visible(tool === 'brush' || tool === 'eraser');
+ });
+
+ // Create the brush preview group & circles
+ const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
+ const brushPreviewFill = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_FILL_ID,
+ listening: false,
+ strokeEnabled: false,
+ });
+ brushPreviewGroup.add(brushPreviewFill);
+ const brushPreviewBorderInner = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
+ listening: false,
+ stroke: BRUSH_BORDER_INNER_COLOR,
+ strokeWidth: 1,
+ strokeEnabled: true,
+ });
+ brushPreviewGroup.add(brushPreviewBorderInner);
+ const brushPreviewBorderOuter = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
+ listening: false,
+ stroke: BRUSH_BORDER_OUTER_COLOR,
+ strokeWidth: 1,
+ strokeEnabled: true,
+ });
+ brushPreviewGroup.add(brushPreviewBorderOuter);
+ toolPreviewLayer.add(brushPreviewGroup);
+
+ // Create the rect preview
+ const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
+ toolPreviewLayer.add(rectPreview);
+
+ return toolPreviewLayer;
+};
+
/**
* Renders the brush preview for the selected tool.
* @param stage The konva stage to render on.
@@ -60,13 +128,14 @@ const selectVectorMaskObjects = (node: Konva.Node) => {
* @param lastMouseDownPos The position of the last mouse down event - used for the rect tool.
* @param brushSize The brush size.
*/
-const toolPreview = (
+const renderToolPreview = (
stage: Konva.Stage,
tool: Tool,
color: RgbColor | null,
globalMaskLayerOpacity: number,
cursorPos: Vector2d | null,
lastMouseDownPos: Vector2d | null,
+ isMouseOver: boolean,
brushSize: number
) => {
const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length;
@@ -85,65 +154,9 @@ const toolPreview = (
stage.container().style.cursor = 'none';
}
- let toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`);
-
- // Create the layer if it doesn't exist
- if (!toolPreviewLayer) {
- // Initialize the brush preview layer & add to the stage
- toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false });
- stage.add(toolPreviewLayer);
-
- // Add handlers to show/hide the brush preview layer
- stage.on('mousemove', (e) => {
- const tool = $tool.get();
- e.target
- .getStage()
- ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible(tool === 'brush' || tool === 'eraser');
- });
- stage.on('mouseleave', (e) => {
- e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
- });
- stage.on('mouseenter', (e) => {
- const tool = $tool.get();
- e.target
- .getStage()
- ?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible(tool === 'brush' || tool === 'eraser');
- });
-
- // Create the brush preview group & circles
- const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
- const brushPreviewFill = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_FILL_ID,
- listening: false,
- strokeEnabled: false,
- });
- brushPreviewGroup.add(brushPreviewFill);
- const brushPreviewBorderInner = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
- listening: false,
- stroke: BRUSH_BORDER_INNER_COLOR,
- strokeWidth: 1,
- strokeEnabled: true,
- });
- brushPreviewGroup.add(brushPreviewBorderInner);
- const brushPreviewBorderOuter = new Konva.Circle({
- id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
- listening: false,
- stroke: BRUSH_BORDER_OUTER_COLOR,
- strokeWidth: 1,
- strokeEnabled: true,
- });
- brushPreviewGroup.add(brushPreviewBorderOuter);
- toolPreviewLayer.add(brushPreviewGroup);
-
- // Create the rect preview
- const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
- toolPreviewLayer.add(rectPreview);
- }
+ const toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`) ?? createToolPreviewLayer(stage);
- if (!$isMouseOver.get() || layerCount === 0) {
+ if (!isMouseOver || layerCount === 0) {
// We can bail early if the mouse isn't over the stage or there are no layers
toolPreviewLayer.visible(false);
return;
@@ -200,85 +213,148 @@ const toolPreview = (
}
};
-const vectorMaskLayer = (
+/**
+ * Creates a vector mask layer.
+ * @param stage The konva stage to attach the layer to.
+ * @param reduxLayer The redux layer to create the konva layer from.
+ * @param onLayerPosChanged Callback for when the layer's position changes.
+ */
+const createVectorMaskLayer = (
stage: Konva.Stage,
- vmLayer: VectorMaskLayer,
- vmLayerIndex: number,
- globalMaskLayerOpacity: number,
- tool: Tool,
+ reduxLayer: VectorMaskLayer,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
- let konvaLayer = stage.findOne(`#${vmLayer.id}`);
-
- if (!konvaLayer) {
- // This layer hasn't been added to the konva state yet
- konvaLayer = new Konva.Layer({
- id: vmLayer.id,
- name: VECTOR_MASK_LAYER_NAME,
- draggable: true,
- dragDistance: 0,
+ // This layer hasn't been added to the konva state yet
+ const konvaLayer = new Konva.Layer({
+ id: reduxLayer.id,
+ name: VECTOR_MASK_LAYER_NAME,
+ draggable: true,
+ dragDistance: 0,
+ });
+
+ // Create a `dragmove` listener for this layer
+ if (onLayerPosChanged) {
+ konvaLayer.on('dragend', function (e) {
+ onLayerPosChanged(reduxLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
});
+ }
- // Create a `dragmove` listener for this layer
- if (onLayerPosChanged) {
- konvaLayer.on('dragend', function (e) {
- onLayerPosChanged(vmLayer.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
- });
+ // The dragBoundFunc limits how far the layer can be dragged
+ konvaLayer.dragBoundFunc(function (pos) {
+ const cursorPos = getScaledFlooredCursorPosition(stage);
+ if (!cursorPos) {
+ return this.getAbsolutePosition();
+ }
+ // Prevent the user from dragging the layer out of the stage bounds.
+ if (
+ cursorPos.x < 0 ||
+ cursorPos.x > stage.width() / stage.scaleX() ||
+ cursorPos.y < 0 ||
+ cursorPos.y > stage.height() / stage.scaleY()
+ ) {
+ return this.getAbsolutePosition();
}
+ return pos;
+ });
- // The dragBoundFunc limits how far the layer can be dragged
- konvaLayer.dragBoundFunc(function (pos) {
- const cursorPos = getScaledFlooredCursorPosition(stage);
- if (!cursorPos) {
- return this.getAbsolutePosition();
- }
- // Prevent the user from dragging the layer out of the stage bounds.
- if (
- cursorPos.x < 0 ||
- cursorPos.x > stage.width() / stage.scaleX() ||
- cursorPos.y < 0 ||
- cursorPos.y > stage.height() / stage.scaleY()
- ) {
- return this.getAbsolutePosition();
- }
- return pos;
- });
+ // The object group holds all of the layer's objects (e.g. lines and rects)
+ const konvaObjectGroup = new Konva.Group({
+ id: getVectorMaskLayerObjectGroupId(reduxLayer.id, uuidv4()),
+ name: VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
+ listening: false,
+ });
+ konvaLayer.add(konvaObjectGroup);
- // The object group holds all of the layer's objects (e.g. lines and rects)
- const konvaObjectGroup = new Konva.Group({
- id: getVectorMaskLayerObjectGroupId(vmLayer.id, uuidv4()),
- name: VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
- listening: false,
- });
- konvaLayer.add(konvaObjectGroup);
+ stage.add(konvaLayer);
- stage.add(konvaLayer);
+ // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top.
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.moveToTop();
- // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top.
- stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.moveToTop();
- }
+ return konvaLayer;
+};
+
+/**
+ * Creates a konva line from a redux vector mask line.
+ * @param reduxObject The redux object to create the konva line from.
+ * @param konvaGroup The konva group to add the line to.
+ */
+const createVectorMaskLine = (reduxObject: VectorMaskLine, konvaGroup: Konva.Group): Konva.Line => {
+ const vectorMaskLine = new Konva.Line({
+ id: reduxObject.id,
+ key: reduxObject.id,
+ name: VECTOR_MASK_LAYER_LINE_NAME,
+ strokeWidth: reduxObject.strokeWidth,
+ tension: 0,
+ lineCap: 'round',
+ lineJoin: 'round',
+ shadowForStrokeEnabled: false,
+ globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
+ listening: false,
+ });
+ konvaGroup.add(vectorMaskLine);
+ return vectorMaskLine;
+};
+
+/**
+ * Creates a konva rect from a redux vector mask rect.
+ * @param reduxObject The redux object to create the konva rect from.
+ * @param konvaGroup The konva group to add the rect to.
+ */
+const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Group): Konva.Rect => {
+ const vectorMaskRect = new Konva.Rect({
+ id: reduxObject.id,
+ key: reduxObject.id,
+ name: VECTOR_MASK_LAYER_RECT_NAME,
+ x: reduxObject.x,
+ y: reduxObject.y,
+ width: reduxObject.width,
+ height: reduxObject.height,
+ listening: false,
+ });
+ konvaGroup.add(vectorMaskRect);
+ return vectorMaskRect;
+};
+
+/**
+ * Renders a vector mask layer.
+ * @param stage The konva stage to render on.
+ * @param reduxLayer The redux vector mask layer to render.
+ * @param reduxLayerIndex The index of the layer in the redux store.
+ * @param globalMaskLayerOpacity The opacity of the global mask layer.
+ * @param tool The current tool.
+ */
+const renderVectorMaskLayer = (
+ stage: Konva.Stage,
+ reduxLayer: VectorMaskLayer,
+ reduxLayerIndex: number,
+ globalMaskLayerOpacity: number,
+ tool: Tool,
+ onLayerPosChanged?: (layerId: string, x: number, y: number) => void
+): void => {
+ const konvaLayer =
+ stage.findOne(`#${reduxLayer.id}`) ?? createVectorMaskLayer(stage, reduxLayer, onLayerPosChanged);
// Update the layer's position and listening state
konvaLayer.setAttrs({
listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
- x: Math.floor(vmLayer.x),
- y: Math.floor(vmLayer.y),
+ x: Math.floor(reduxLayer.x),
+ y: Math.floor(reduxLayer.y),
// We have a konva layer for each redux layer, plus a brush preview layer, which should always be on top. We can
// therefore use the index of the redux layer as the zIndex for konva layers. If more layers are added to the
// stage, this may no longer be work.
- zIndex: vmLayerIndex,
+ zIndex: reduxLayerIndex,
});
// Convert the color to a string, stripping the alpha - the object group will handle opacity.
- const rgbColor = rgbColorToString(vmLayer.previewColor);
+ const rgbColor = rgbColorToString(reduxLayer.previewColor);
const konvaObjectGroup = konvaLayer.findOne(`.${VECTOR_MASK_LAYER_OBJECT_GROUP_NAME}`);
- assert(konvaObjectGroup, `Object group not found for layer ${vmLayer.id}`);
+ assert(konvaObjectGroup, `Object group not found for layer ${reduxLayer.id}`);
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false;
- const objectIds = vmLayer.objects.map(mapId);
+ const objectIds = reduxLayer.objects.map(mapId);
for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
if (!objectIds.includes(objectNode.id())) {
objectNode.destroy();
@@ -286,26 +362,10 @@ const vectorMaskLayer = (
}
}
- for (const reduxObject of vmLayer.objects) {
+ for (const reduxObject of reduxLayer.objects) {
if (reduxObject.type === 'vector_mask_line') {
- let vectorMaskLine = stage.findOne(`#${reduxObject.id}`);
-
- // Create the line if it doesn't exist
- if (!vectorMaskLine) {
- vectorMaskLine = new Konva.Line({
- id: reduxObject.id,
- key: reduxObject.id,
- name: VECTOR_MASK_LAYER_LINE_NAME,
- strokeWidth: reduxObject.strokeWidth,
- tension: 0,
- lineCap: 'round',
- lineJoin: 'round',
- shadowForStrokeEnabled: false,
- globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out',
- listening: false,
- });
- konvaObjectGroup.add(vectorMaskLine);
- }
+ const vectorMaskLine =
+ stage.findOne(`#${reduxObject.id}`) ?? createVectorMaskLine(reduxObject, konvaObjectGroup);
// Only update the points if they have changed. The point values are never mutated, they are only added to the
// array, so checking the length is sufficient to determine if we need to re-cache.
@@ -319,20 +379,9 @@ const vectorMaskLayer = (
groupNeedsCache = true;
}
} else if (reduxObject.type === 'vector_mask_rect') {
- let konvaObject = stage.findOne(`#${reduxObject.id}`);
- if (!konvaObject) {
- konvaObject = new Konva.Rect({
- id: reduxObject.id,
- key: reduxObject.id,
- name: VECTOR_MASK_LAYER_RECT_NAME,
- x: reduxObject.x,
- y: reduxObject.y,
- width: reduxObject.width,
- height: reduxObject.height,
- listening: false,
- });
- konvaObjectGroup.add(konvaObject);
- }
+ const konvaObject =
+ stage.findOne(`#${reduxObject.id}`) ?? createVectorMaskRect(reduxObject, konvaObjectGroup);
+
// Only update the color if it has changed.
if (konvaObject.fill() !== rgbColor) {
konvaObject.fill(rgbColor);
@@ -342,20 +391,16 @@ const vectorMaskLayer = (
}
// Only update layer visibility if it has changed.
- if (konvaLayer.visible() !== vmLayer.isVisible) {
- konvaLayer.visible(vmLayer.isVisible);
+ if (konvaLayer.visible() !== reduxLayer.isVisible) {
+ konvaLayer.visible(reduxLayer.isVisible);
groupNeedsCache = true;
}
- if (konvaObjectGroup.children.length > 0) {
- // If we have objects, we need to cache the group to apply the layer opacity...
- if (groupNeedsCache) {
- // ...but only if we've done something that needs the cache.
- konvaObjectGroup.cache();
- }
- } else {
- // No children - clear the cache to reset the previous pixel data
+ if (konvaObjectGroup.children.length === 0) {
+ // No objects - clear the cache to reset the previous pixel data
konvaObjectGroup.clearCache();
+ } else if (groupNeedsCache) {
+ konvaObjectGroup.cache();
}
// Updating group opacity does not require re-caching
@@ -372,7 +417,7 @@ const vectorMaskLayer = (
* @param onLayerPosChanged Callback for when the layer's position changes. This is optional to allow for offscreen rendering.
* @returns
*/
-const layers = (
+const renderLayers = (
stage: Konva.Stage,
reduxLayers: Layer[],
globalMaskLayerOpacity: number,
@@ -392,20 +437,55 @@ const layers = (
const reduxLayer = reduxLayers[layerIndex];
assert(reduxLayer, `Layer at index ${layerIndex} is undefined`);
if (isVectorMaskLayer(reduxLayer)) {
- vectorMaskLayer(stage, reduxLayer, layerIndex, globalMaskLayerOpacity, tool, onLayerPosChanged);
+ renderVectorMaskLayer(stage, reduxLayer, layerIndex, globalMaskLayerOpacity, tool, onLayerPosChanged);
}
}
};
/**
- *
- * @param stage The konva stage to render on.
- * @param tool The current tool.
- * @param selectedLayerIdId The currently selected layer id.
- * @param onBboxChanged A callback to be called when the bounding box changes.
+ * Creates a bounding box rect for a layer.
+ * @param reduxLayer The redux layer to create the bounding box for.
+ * @param konvaLayer The konva layer to attach the bounding box to.
+ * @param onBboxMouseDown Callback for when the bounding box is clicked.
+ */
+const createBboxRect = (reduxLayer: Layer, konvaLayer: Konva.Layer, onBboxMouseDown: (layerId: string) => void) => {
+ const rect = new Konva.Rect({
+ id: getLayerBboxId(reduxLayer.id),
+ name: LAYER_BBOX_NAME,
+ strokeWidth: 1,
+ });
+ rect.on('mousedown', function () {
+ onBboxMouseDown(reduxLayer.id);
+ });
+ rect.on('mouseover', function (e) {
+ if (getIsSelected(e.target.getLayer()?.id())) {
+ this.stroke(BBOX_SELECTED_STROKE);
+ } else {
+ this.stroke(BBOX_NOT_SELECTED_MOUSEOVER_STROKE);
+ }
+ });
+ rect.on('mouseout', function (e) {
+ if (getIsSelected(e.target.getLayer()?.id())) {
+ this.stroke(BBOX_SELECTED_STROKE);
+ } else {
+ this.stroke(BBOX_NOT_SELECTED_STROKE);
+ }
+ });
+ konvaLayer.add(rect);
+ return rect;
+};
+
+/**
+ * Renders the bounding boxes for the layers.
+ * @param stage The konva stage to render on
+ * @param reduxLayers An array of all redux layers to draw bboxes for
+ * @param selectedLayerId The selected layer's id
+ * @param tool The current tool
+ * @param onBboxChanged Callback for when the bbox is changed
+ * @param onBboxMouseDown Callback for when the bbox is clicked
* @returns
*/
-const bbox = (
+const renderBbox = (
stage: Konva.Stage,
reduxLayers: Layer[],
selectedLayerId: string | null,
@@ -433,7 +513,6 @@ const bbox = (
if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) {
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes
bbox = reduxLayer.needsPixelBbox ? getLayerBboxPixels(konvaLayer) : getLayerBboxFast(konvaLayer);
-
// Update the layer's bbox in the redux store
onBboxChanged(reduxLayer.id, bbox);
}
@@ -442,32 +521,8 @@ const bbox = (
continue;
}
- let rect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`);
- if (!rect) {
- rect = new Konva.Rect({
- id: getLayerBboxId(reduxLayer.id),
- name: LAYER_BBOX_NAME,
- strokeWidth: 1,
- });
- rect.on('mousedown', function () {
- onBboxMouseDown(reduxLayer.id);
- });
- rect.on('mouseover', function (e) {
- if (getIsSelected(e.target.getLayer()?.id())) {
- this.stroke(BBOX_SELECTED_STROKE);
- } else {
- this.stroke(BBOX_NOT_SELECTED_MOUSEOVER_STROKE);
- }
- });
- rect.on('mouseout', function (e) {
- if (getIsSelected(e.target.getLayer()?.id())) {
- this.stroke(BBOX_SELECTED_STROKE);
- } else {
- this.stroke(BBOX_NOT_SELECTED_STROKE);
- }
- });
- konvaLayer.add(rect);
- }
+ const rect =
+ konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(reduxLayer, konvaLayer, onBboxMouseDown);
rect.setAttrs({
visible: true,
@@ -481,31 +536,41 @@ const bbox = (
}
};
-const background = (stage: Konva.Stage, width: number, height: number) => {
- let layer = stage.findOne(`#${BACKGROUND_LAYER_ID}`);
+/**
+ * Creates the background layer for the stage.
+ * @param stage The konva stage to render on
+ */
+const createBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
+ const layer = new Konva.Layer({
+ id: BACKGROUND_LAYER_ID,
+ });
+ const background = new Konva.Rect({
+ id: BACKGROUND_RECT_ID,
+ x: stage.x(),
+ y: 0,
+ width: stage.width() / stage.scaleX(),
+ height: stage.height() / stage.scaleY(),
+ listening: false,
+ opacity: 0.2,
+ });
+ layer.add(background);
+ stage.add(layer);
+ const image = new Image();
+ image.onload = () => {
+ background.fillPatternImage(image);
+ };
+ image.src = STAGE_BG_DATAURL;
+ return layer;
+};
- if (!layer) {
- layer = new Konva.Layer({
- id: BACKGROUND_LAYER_ID,
- });
- const background = new Konva.Rect({
- id: BACKGROUND_RECT_ID,
- x: stage.x(),
- y: 0,
- width: stage.width() / stage.scaleX(),
- height: stage.height() / stage.scaleY(),
- listening: false,
- opacity: 0.2,
- });
- layer.add(background);
- stage.add(layer);
- const image = new Image();
- image.onload = () => {
- background.fillPatternImage(image);
- };
- // This is invokeai/frontend/web/public/assets/images/transparent_bg.png as a dataURL
- image.src = STAGE_BG_DATAURL;
- }
+/**
+ * Renders the background layer for the stage.
+ * @param stage The konva stage to render on
+ * @param width The unscaled width of the canvas
+ * @param height The unscaled height of the canvas
+ */
+const renderBackground = (stage: Konva.Stage, width: number, height: number) => {
+ const layer = stage.findOne(`#${BACKGROUND_LAYER_ID}`) ?? createBackgroundLayer(stage);
const background = layer.findOne(`#${BACKGROUND_RECT_ID}`);
assert(background, 'Background rect not found');
@@ -528,15 +593,18 @@ const background = (stage: Konva.Stage, width: number, height: number) => {
background.fillPatternOffset(stagePos);
};
+export const renderers = {
+ renderToolPreview,
+ renderLayers,
+ renderBbox,
+ renderBackground,
+};
+
const DEBOUNCE_MS = 300;
-export const renderers = {
- toolPreview,
- toolPreviewDebounced: debounce(toolPreview, DEBOUNCE_MS),
- layers,
- layersDebounced: debounce(layers, DEBOUNCE_MS),
- bbox,
- bboxDebounced: debounce(bbox, DEBOUNCE_MS),
- background,
- backgroundDebounced: debounce(background, DEBOUNCE_MS),
+export const debouncedRenderers = {
+ renderToolPreview: debounce(renderToolPreview, DEBOUNCE_MS),
+ renderLayers: debounce(renderLayers, DEBOUNCE_MS),
+ renderBbox: debounce(renderBbox, DEBOUNCE_MS),
+ renderBackground: debounce(renderBackground, DEBOUNCE_MS),
};
From 73e74e547864d9d8ec4088453c125052ad0aafaa Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 12:02:06 +1000
Subject: [PATCH 6/9] feat(ui): create new line when mouse held down, leaves
canvas and comes back over
---
.../regionalPrompts/hooks/mouseEventHooks.ts | 59 ++++++++++++-------
1 file changed, 39 insertions(+), 20 deletions(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
index c0fdfc7c727..0bcd6f4e109 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
@@ -15,6 +15,7 @@ import {
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
+import type { Vector2d } from 'konva/lib/types';
import { useCallback, useRef } from 'react';
const getIsFocused = (stage: Konva.Stage) => {
@@ -23,21 +24,26 @@ const getIsFocused = (stage: Konva.Stage) => {
export const getScaledFlooredCursorPosition = (stage: Konva.Stage) => {
const pointerPosition = stage.getPointerPosition();
-
const stageTransform = stage.getAbsoluteTransform().copy();
-
if (!pointerPosition || !stageTransform) {
return;
}
-
const scaledCursorPosition = stageTransform.invert().point(pointerPosition);
-
return {
x: Math.floor(scaledCursorPosition.x),
y: Math.floor(scaledCursorPosition.y),
};
};
+const syncCursorPos = (stage: Konva.Stage): Vector2d | null => {
+ const pos = getScaledFlooredCursorPosition(stage);
+ if (!pos) {
+ return null;
+ }
+ $cursorPosition.set(pos);
+ return pos;
+};
+
export const useMouseEvents = () => {
const dispatch = useAppDispatch();
const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId);
@@ -52,7 +58,7 @@ export const useMouseEvents = () => {
if (!stage) {
return;
}
- const pos = $cursorPosition.get();
+ const pos = syncCursorPos(stage);
if (!pos) {
return;
}
@@ -66,7 +72,7 @@ export const useMouseEvents = () => {
dispatch(
maskLayerLineAdded({
layerId: selectedLayerId,
- points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)],
+ points: [pos.x, pos.y, pos.x, pos.y],
tool,
})
);
@@ -109,33 +115,46 @@ export const useMouseEvents = () => {
if (!stage) {
return;
}
- const pos = getScaledFlooredCursorPosition(stage);
+ const pos = syncCursorPos(stage);
if (!pos || !selectedLayerId) {
return;
}
- $cursorPosition.set(pos);
if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
if (lastCursorPosRef.current) {
if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < 20) {
return;
}
}
- lastCursorPosRef.current = [Math.floor(pos.x), Math.floor(pos.y)];
+ lastCursorPosRef.current = [pos.x, pos.y];
dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: lastCursorPosRef.current }));
}
},
[dispatch, selectedLayerId, tool]
);
- const onMouseLeave = useCallback((e: KonvaEventObject) => {
- const stage = e.target.getStage();
- if (!stage) {
- return;
- }
- $isMouseOver.set(false);
- $isMouseDown.set(false);
- $cursorPosition.set(null);
- }, []);
+ const onMouseLeave = useCallback(
+ (e: KonvaEventObject) => {
+ const stage = e.target.getStage();
+ if (!stage) {
+ return;
+ }
+ const pos = syncCursorPos(stage);
+ if (
+ pos &&
+ selectedLayerId &&
+ getIsFocused(stage) &&
+ $isMouseOver.get() &&
+ $isMouseDown.get() &&
+ (tool === 'brush' || tool === 'eraser')
+ ) {
+ dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [pos.x, pos.y] }));
+ }
+ $isMouseOver.set(false);
+ $isMouseDown.set(false);
+ $cursorPosition.set(null);
+ },
+ [selectedLayerId, tool, dispatch]
+ );
const onMouseEnter = useCallback(
(e: KonvaEventObject) => {
@@ -144,7 +163,7 @@ export const useMouseEvents = () => {
return;
}
$isMouseOver.set(true);
- const pos = $cursorPosition.get();
+ const pos = syncCursorPos(stage);
if (!pos) {
return;
}
@@ -162,7 +181,7 @@ export const useMouseEvents = () => {
dispatch(
maskLayerLineAdded({
layerId: selectedLayerId,
- points: [Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.x), Math.floor(pos.y)],
+ points: [pos.x, pos.y, pos.x, pos.y],
tool,
})
);
From 27d14623d0dae08103c464bd3a833c7a8cc56839 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 12:23:08 +1000
Subject: [PATCH 7/9] tidy(ui): use const for brush spacing
---
.../src/features/regionalPrompts/hooks/mouseEventHooks.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
index 0bcd6f4e109..fc58de60ed3 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
@@ -44,6 +44,8 @@ const syncCursorPos = (stage: Konva.Stage): Vector2d | null => {
return pos;
};
+const BRUSH_SPACING = 20;
+
export const useMouseEvents = () => {
const dispatch = useAppDispatch();
const selectedLayerId = useAppSelector((s) => s.regionalPrompts.present.selectedLayerId);
@@ -67,7 +69,6 @@ export const useMouseEvents = () => {
if (!selectedLayerId) {
return;
}
- // const tool = getTool();
if (tool === 'brush' || tool === 'eraser') {
dispatch(
maskLayerLineAdded({
@@ -121,7 +122,8 @@ export const useMouseEvents = () => {
}
if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
if (lastCursorPosRef.current) {
- if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < 20) {
+ // Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
+ if (Math.hypot(lastCursorPosRef.current[0] - pos.x, lastCursorPosRef.current[1] - pos.y) < BRUSH_SPACING) {
return;
}
}
From 29be000f0aa14425872f4d448b503ff58a86a9a7 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 16:32:04 +1000
Subject: [PATCH 8/9] fix(ui): fix layer arrangement
---
.../components/StageComponent.tsx | 44 +++++++++----------
.../regionalPrompts/util/renderers.ts | 33 +++++++++-----
2 files changed, 41 insertions(+), 36 deletions(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
index 97fe0a5c533..f286b75711e 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
@@ -15,7 +15,7 @@ import {
layerTranslated,
selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
-import { debouncedRenderers, renderers } from 'features/regionalPrompts/util/renderers';
+import { debouncedRenderers, renderers as normalRenderers } from 'features/regionalPrompts/util/renderers';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import type { MutableRefObject } from 'react';
@@ -52,20 +52,8 @@ const useStageRenderer = (
const lastMouseDownPos = useStore($lastMouseDownPos);
const isMouseOver = useStore($isMouseOver);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
-
- const renderLayers = useMemo(
- () => (asPreview ? debouncedRenderers.renderLayers : renderers.renderLayers),
- [asPreview]
- );
- const renderToolPreview = useMemo(
- () => (asPreview ? debouncedRenderers.renderToolPreview : renderers.renderToolPreview),
- [asPreview]
- );
- const renderBbox = useMemo(() => (asPreview ? debouncedRenderers.renderBbox : renderers.renderBbox), [asPreview]);
- const renderBackground = useMemo(
- () => (asPreview ? debouncedRenderers.renderBackground : renderers.renderBackground),
- [asPreview]
- );
+ const layerIds = useMemo(() => state.layers.map((l) => l.id), [state.layers]);
+ const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
const onLayerPosChanged = useCallback(
(layerId: string, x: number, y: number) => {
@@ -152,11 +140,12 @@ const useStageRenderer = (
}, [stageRef, width, height, wrapper]);
useLayoutEffect(() => {
- log.trace('Rendering brush preview');
+ log.trace('Rendering tool preview');
if (asPreview) {
+ // Preview should not display tool
return;
}
- renderToolPreview(
+ renderers.renderToolPreview(
stageRef.current,
tool,
selectedLayerIdColor,
@@ -176,29 +165,36 @@ const useStageRenderer = (
lastMouseDownPos,
isMouseOver,
state.brushSize,
- renderToolPreview,
+ renderers,
]);
useLayoutEffect(() => {
log.trace('Rendering layers');
- renderLayers(stageRef.current, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
- }, [stageRef, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged, renderLayers]);
+ renderers.renderLayers(stageRef.current, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged);
+ }, [stageRef, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged, renderers]);
useLayoutEffect(() => {
log.trace('Rendering bbox');
if (asPreview) {
+ // Preview should not display bboxes
return;
}
- renderBbox(stageRef.current, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown);
- }, [stageRef, asPreview, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown, renderBbox]);
+ renderers.renderBbox(stageRef.current, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown);
+ }, [stageRef, asPreview, state.layers, state.selectedLayerId, tool, onBboxChanged, onBboxMouseDown, renderers]);
useLayoutEffect(() => {
log.trace('Rendering background');
if (asPreview) {
+ // The preview should not have a background
return;
}
- renderBackground(stageRef.current, width, height);
- }, [stageRef, asPreview, width, height, renderBackground]);
+ renderers.renderBackground(stageRef.current, width, height);
+ }, [stageRef, asPreview, width, height, renderers]);
+
+ useLayoutEffect(() => {
+ log.trace('Arranging layers');
+ renderers.arrangeLayers(stageRef.current, layerIds);
+ }, [stageRef, layerIds, renderers]);
};
type Props = {
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
index 76c9bb4f93b..4e999fd60b1 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
@@ -267,9 +267,6 @@ const createVectorMaskLayer = (
stage.add(konvaLayer);
- // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top.
- stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.moveToTop();
-
return konvaLayer;
};
@@ -326,7 +323,6 @@ const createVectorMaskRect = (reduxObject: VectorMaskRect, konvaGroup: Konva.Gro
const renderVectorMaskLayer = (
stage: Konva.Stage,
reduxLayer: VectorMaskLayer,
- reduxLayerIndex: number,
globalMaskLayerOpacity: number,
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
@@ -339,10 +335,6 @@ const renderVectorMaskLayer = (
listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
x: Math.floor(reduxLayer.x),
y: Math.floor(reduxLayer.y),
- // We have a konva layer for each redux layer, plus a brush preview layer, which should always be on top. We can
- // therefore use the index of the redux layer as the zIndex for konva layers. If more layers are added to the
- // stage, this may no longer be work.
- zIndex: reduxLayerIndex,
});
// Convert the color to a string, stripping the alpha - the object group will handle opacity.
@@ -433,11 +425,9 @@ const renderLayers = (
}
}
- for (let layerIndex = 0; layerIndex < reduxLayers.length; layerIndex++) {
- const reduxLayer = reduxLayers[layerIndex];
- assert(reduxLayer, `Layer at index ${layerIndex} is undefined`);
+ for (const reduxLayer of reduxLayers) {
if (isVectorMaskLayer(reduxLayer)) {
- renderVectorMaskLayer(stage, reduxLayer, layerIndex, globalMaskLayerOpacity, tool, onLayerPosChanged);
+ renderVectorMaskLayer(stage, reduxLayer, globalMaskLayerOpacity, tool, onLayerPosChanged);
}
}
};
@@ -593,11 +583,29 @@ const renderBackground = (stage: Konva.Stage, width: number, height: number) =>
background.fillPatternOffset(stagePos);
};
+/**
+ * Arranges all layers in the z-axis by updating their z-indices.
+ * @param stage The konva stage
+ * @param layerIds An array of redux layer ids, in their z-index order
+ */
+export const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
+ let nextZIndex = 0;
+ // Background is the first layer
+ stage.findOne(`#${BACKGROUND_LAYER_ID}`)?.zIndex(nextZIndex++);
+ // Then arrange the redux layers in order
+ for (const layerId of layerIds) {
+ stage.findOne(`#${layerId}`)?.zIndex(nextZIndex++);
+ }
+ // Finally, the tool preview layer is always on top
+ stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(nextZIndex++);
+};
+
export const renderers = {
renderToolPreview,
renderLayers,
renderBbox,
renderBackground,
+ arrangeLayers,
};
const DEBOUNCE_MS = 300;
@@ -607,4 +615,5 @@ export const debouncedRenderers = {
renderLayers: debounce(renderLayers, DEBOUNCE_MS),
renderBbox: debounce(renderBbox, DEBOUNCE_MS),
renderBackground: debounce(renderBackground, DEBOUNCE_MS),
+ arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
};
From 5c06fa3245487c2e5850a2f7cd43fd402630a627 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 24 Apr 2024 19:33:41 +1000
Subject: [PATCH 9/9] chore(ui): lint
---
.../frontend/web/src/features/regionalPrompts/util/renderers.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
index 4e999fd60b1..20e5f75ab79 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
@@ -588,7 +588,7 @@ const renderBackground = (stage: Konva.Stage, width: number, height: number) =>
* @param stage The konva stage
* @param layerIds An array of redux layer ids, in their z-index order
*/
-export const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
+const arrangeLayers = (stage: Konva.Stage, layerIds: string[]): void => {
let nextZIndex = 0;
// Background is the first layer
stage.findOne(`#${BACKGROUND_LAYER_ID}`)?.zIndex(nextZIndex++);