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++);