refactor(ui): port remaining canvasV1 rendering logic to V2, remove old code

This commit is contained in:
psychedelicious 2024-06-16 20:04:53 +10:00
parent 7ef4553fc9
commit 5184d05bc2
89 changed files with 497 additions and 4914 deletions

View File

@ -4,9 +4,9 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { parseify } from 'common/util/serialize';
import { canvasBatchIdAdded, stagingAreaInitialized } from 'features/canvas/store/canvasSlice';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { blobToDataURL } from "features/controlLayers/konva/util";
import { canvasGraphBuilt } from 'features/nodes/store/actions';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildCanvasGraph } from 'features/nodes/util/graph/canvas/buildCanvasGraph';

View File

@ -1,7 +1,6 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch, RootState } from 'app/store/store';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import {
caImageChanged,
caProcessedImageChanged,
@ -160,10 +159,6 @@ export const addImageDeletionListeners = (startAppListening: AppStartListening)
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imagesUsage.some((i) => i.isLayerImage)) {
dispatch(resetCanvas());
}
imageDTOs.forEach((imageDTO) => {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlAdapterImages(state, dispatch, imageDTO);

View File

@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { parseify } from 'common/util/serialize';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import {
boardIdSelected,
galleryViewChanged,
@ -12,7 +11,6 @@ import {
} from 'features/gallery/store/gallerySlice';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { getCategories, getListImagesUrl } from 'services/api/util';
@ -47,11 +45,10 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
imageDTORequest.unsubscribe();
// Add canvas images to the staging area
// TODO(psyche): canvas batchid processing, [] -> canvas.batchIds
// TODO(psyche): canvas batchid processing
// if (canvas.batchIds.includes(data.batch_id) && data.invocation_source_id === CANVAS_OUTPUT) {
if ([].includes(data.batch_id) && data.invocation_source_id === CANVAS_OUTPUT) {
dispatch(addImageToStagingArea(imageDTO));
}
// dispatch(addImageToStagingArea(imageDTO));
// }
if (!imageDTO.is_intermediate) {
// update the total images for the board

View File

@ -1,35 +0,0 @@
import { Button, ConfirmationAlertDialog, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { clearCanvasHistory } from 'features/canvas/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
const ClearCanvasHistoryButtonModal = () => {
const isStaging = useAppSelector(isStagingSelector);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const acceptCallback = useCallback(() => dispatch(clearCanvasHistory()), [dispatch]);
return (
<>
<Button onClick={onOpen} size="sm" leftIcon={<PiTrashSimpleFill />} isDisabled={isStaging}>
{t('unifiedCanvas.clearCanvasHistory')}
</Button>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('unifiedCanvas.clearCanvasHistory')}
acceptCallback={acceptCallback}
acceptButtonText={t('unifiedCanvas.clearHistory')}
>
<p>{t('unifiedCanvas.clearCanvasHistoryMessage')}</p>
<br />
<p>{t('unifiedCanvas.clearCanvasHistoryConfirm')}</p>
</ConfirmationAlertDialog>
</>
);
};
export default memo(ClearCanvasHistoryButtonModal);

View File

@ -1,198 +0,0 @@
import { Box, chakra, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import useCanvasDragMove from 'features/canvas/hooks/useCanvasDragMove';
import useCanvasHotkeys from 'features/canvas/hooks/useCanvasHotkeys';
import useCanvasMouseDown from 'features/canvas/hooks/useCanvasMouseDown';
import useCanvasMouseMove from 'features/canvas/hooks/useCanvasMouseMove';
import useCanvasMouseOut from 'features/canvas/hooks/useCanvasMouseOut';
import useCanvasMouseUp from 'features/canvas/hooks/useCanvasMouseUp';
import useCanvasWheel from 'features/canvas/hooks/useCanvasZoom';
import {
$canvasBaseLayer,
$canvasStage,
$isModifyingBoundingBox,
$isMouseOverBoundingBox,
$isMovingStage,
$isTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { canvasResized, selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { Layer, Stage } from 'react-konva';
import IAICanvasBoundingBoxOverlay from './IAICanvasBoundingBoxOverlay';
import IAICanvasGrid from './IAICanvasGrid';
import IAICanvasIntermediateImage from './IAICanvasIntermediateImage';
import IAICanvasMaskCompositor from './IAICanvasMaskCompositor';
import IAICanvasMaskLines from './IAICanvasMaskLines';
import IAICanvasObjectRenderer from './IAICanvasObjectRenderer';
import IAICanvasStagingArea from './IAICanvasStagingArea';
import IAICanvasStagingAreaToolbar from './IAICanvasStagingAreaToolbar';
import IAICanvasStatusText from './IAICanvasStatusText';
import IAICanvasBoundingBox from './IAICanvasToolbar/IAICanvasBoundingBox';
import IAICanvasToolPreview from './IAICanvasToolPreview';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return {
stageCoordinates: canvas.stageCoordinates,
stageDimensions: canvas.stageDimensions,
};
});
const ChakraStage = chakra(Stage, {
shouldForwardProp: (prop) => !['sx'].includes(prop),
});
const IAICanvas = () => {
const isStaging = useAppSelector(isStagingSelector);
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const shouldShowBoundingBox = useAppSelector((s) => s.canvas.shouldShowBoundingBox);
const shouldShowGrid = useAppSelector((s) => s.canvas.shouldShowGrid);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const shouldShowIntermediates = useAppSelector((s) => s.canvas.shouldShowIntermediates);
const shouldAntialias = useAppSelector((s) => s.canvas.shouldAntialias);
const shouldRestrictStrokesToBox = useAppSelector((s) => s.canvas.shouldRestrictStrokesToBox);
const { stageCoordinates, stageDimensions } = useAppSelector(selector);
const dispatch = useAppDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const canvasBaseLayerRef = useRef<Konva.Layer | null>(null);
const isModifyingBoundingBox = useStore($isModifyingBoundingBox);
const isMovingStage = useStore($isMovingStage);
const isTransformingBoundingBox = useStore($isTransformingBoundingBox);
const isMouseOverBoundingBox = useStore($isMouseOverBoundingBox);
const tool = useStore($tool);
useCanvasHotkeys();
const canvasStageRefCallback = useCallback((stageElement: Konva.Stage) => {
$canvasStage.set(stageElement);
stageRef.current = stageElement;
}, []);
const stageCursor = useMemo(() => {
if (tool === 'move' || isStaging) {
if (isMovingStage) {
return 'grabbing';
} else {
return 'grab';
}
} else if (isTransformingBoundingBox) {
return undefined;
} else if (shouldRestrictStrokesToBox && !isMouseOverBoundingBox) {
return 'default';
}
return 'none';
}, [isMouseOverBoundingBox, isMovingStage, isStaging, isTransformingBoundingBox, shouldRestrictStrokesToBox, tool]);
const canvasBaseLayerRefCallback = useCallback((layerElement: Konva.Layer) => {
$canvasBaseLayer.set(layerElement);
canvasBaseLayerRef.current = layerElement;
}, []);
const lastCursorPositionRef = useRef<Vector2d>({ x: 0, y: 0 });
// Use refs for values that do not affect rendering, other values in redux
const didMouseMoveRef = useRef<boolean>(false);
const handleWheel = useCanvasWheel(stageRef);
const handleMouseDown = useCanvasMouseDown(stageRef);
const handleMouseUp = useCanvasMouseUp(stageRef, didMouseMoveRef);
const handleMouseMove = useCanvasMouseMove(stageRef, didMouseMoveRef, lastCursorPositionRef);
const { handleDragStart, handleDragMove, handleDragEnd } = useCanvasDragMove();
const handleMouseOut = useCanvasMouseOut();
const handleContextMenu = useCallback((e: KonvaEventObject<MouseEvent>) => e.evt.preventDefault(), []);
useEffect(() => {
if (!containerRef.current) {
return;
}
const resizeObserver = new ResizeObserver(() => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
dispatch(canvasResized({ width, height }));
});
resizeObserver.observe(containerRef.current);
const { width, height } = containerRef.current.getBoundingClientRect();
dispatch(canvasResized({ width, height }));
return () => {
resizeObserver.disconnect();
};
}, [dispatch]);
const stageStyles = useMemo(
() => ({
outline: 'none',
overflow: 'hidden',
cursor: stageCursor ? stageCursor : undefined,
canvas: {
outline: 'none',
},
}),
[stageCursor]
);
const scale = useMemo(() => ({ x: stageScale, y: stageScale }), [stageScale]);
return (
<Flex id="canvas-container" ref={containerRef} position="relative" height="100%" width="100%" borderRadius="base">
<Box position="absolute">
<ChakraStage
tabIndex={-1}
ref={canvasStageRefCallback}
sx={stageStyles}
x={stageCoordinates.x}
y={stageCoordinates.y}
width={stageDimensions.width}
height={stageDimensions.height}
scale={scale}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onContextMenu={handleContextMenu}
onWheel={handleWheel}
draggable={(tool === 'move' || isStaging) && !isModifyingBoundingBox}
>
<Layer id="grid" visible={shouldShowGrid} listening={false}>
<IAICanvasGrid />
</Layer>
<Layer id="base" ref={canvasBaseLayerRefCallback} listening={false} imageSmoothingEnabled={shouldAntialias}>
<IAICanvasObjectRenderer />
</Layer>
<Layer id="mask" visible={isMaskEnabled && !isStaging} listening={false}>
<IAICanvasMaskLines visible={true} listening={false} />
<IAICanvasMaskCompositor listening={false} />
</Layer>
<Layer listening={false}>
<IAICanvasBoundingBoxOverlay />
</Layer>
<Layer id="preview" imageSmoothingEnabled={shouldAntialias}>
{!isStaging && <IAICanvasToolPreview visible={tool !== 'move'} listening={false} />}
<IAICanvasStagingArea listening={false} visible={isStaging} />
{shouldShowIntermediates && <IAICanvasIntermediateImage />}
<IAICanvasBoundingBox visible={shouldShowBoundingBox && !isStaging} />
</Layer>
</ChakraStage>
</Box>
<IAICanvasStatusText />
<IAICanvasStagingAreaToolbar />
</Flex>
);
};
export default memo(IAICanvas);

View File

@ -1,48 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { memo } from 'react';
import { Group, Rect } from 'react-konva';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const { boundingBoxCoordinates, boundingBoxDimensions, stageDimensions, stageCoordinates } = canvas;
return {
boundingBoxCoordinates,
boundingBoxDimensions,
stageCoordinates,
stageDimensions,
};
});
const IAICanvasBoundingBoxOverlay = () => {
const { boundingBoxCoordinates, boundingBoxDimensions, stageCoordinates, stageDimensions } = useAppSelector(selector);
const shouldDarkenOutsideBoundingBox = useAppSelector((s) => s.canvas.shouldDarkenOutsideBoundingBox);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
return (
<Group listening={false}>
<Rect
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fill="rgba(0,0,0,0.4)"
listening={false}
visible={shouldDarkenOutsideBoundingBox}
/>
<Rect
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
fill="rgb(255,255,255)"
listening={false}
visible={shouldDarkenOutsideBoundingBox}
globalCompositeOperation="destination-out"
/>
</Group>
);
};
export default memo(IAICanvasBoundingBoxOverlay);

View File

@ -1,126 +0,0 @@
// Grid drawing adapted from https://longviewcoder.com/2021/12/08/konva-a-better-grid/
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import type { ReactElement } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Group, Line as KonvaLine } from 'react-konva';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return {
stageCoordinates: canvas.stageCoordinates,
stageDimensions: canvas.stageDimensions,
};
});
const baseGridLineColor = getArbitraryBaseColor(27);
const fineGridLineColor = getArbitraryBaseColor(18);
const IAICanvasGrid = () => {
const { stageCoordinates, stageDimensions } = useAppSelector(selector);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const gridSpacing = useMemo(() => {
if (stageScale >= 2) {
return 8;
}
if (stageScale >= 1 && stageScale < 2) {
return 16;
}
if (stageScale >= 0.5 && stageScale < 1) {
return 32;
}
return 64;
}, [stageScale]);
const unscale = useCallback(
(value: number) => {
return value / stageScale;
},
[stageScale]
);
const gridLines = useMemo(() => {
const { width, height } = stageDimensions;
const { x, y } = stageCoordinates;
const stageRect = {
x1: 0,
y1: 0,
x2: width,
y2: height,
offset: {
x: unscale(x),
y: unscale(y),
},
};
const gridOffset = {
x: Math.ceil(unscale(x) / gridSpacing) * gridSpacing,
y: Math.ceil(unscale(y) / gridSpacing) * gridSpacing,
};
const gridRect = {
x1: -gridOffset.x,
y1: -gridOffset.y,
x2: unscale(width) - gridOffset.x + gridSpacing,
y2: unscale(height) - gridOffset.y + gridSpacing,
};
const gridFullRect = {
x1: Math.min(stageRect.x1, gridRect.x1),
y1: Math.min(stageRect.y1, gridRect.y1),
x2: Math.max(stageRect.x2, gridRect.x2),
y2: Math.max(stageRect.y2, gridRect.y2),
};
const // find the x & y size of the grid
xSize = gridFullRect.x2 - gridFullRect.x1;
const ySize = gridFullRect.y2 - gridFullRect.y1;
// compute the number of steps required on each axis.
const xSteps = Math.round(xSize / gridSpacing) + 1;
const ySteps = Math.round(ySize / gridSpacing) + 1;
const strokeWidth = unscale(1);
const gridLines: ReactElement[] = new Array(xSteps + ySteps);
let _x = 0;
let _y = 0;
for (let i = 0; i < xSteps; i++) {
_x = gridFullRect.x1 + i * gridSpacing;
gridLines.push(
<KonvaLine
key={`x_${i}`}
x={_x}
y={gridFullRect.y1}
points={[0, 0, 0, ySize]}
stroke={_x % 64 ? fineGridLineColor : baseGridLineColor}
strokeWidth={strokeWidth}
listening={false}
/>
);
}
for (let i = 0; i < ySteps; i++) {
_y = gridFullRect.y1 + i * gridSpacing;
gridLines.push(
<KonvaLine
key={`y_${i}`}
x={gridFullRect.x1}
y={_y}
points={[0, 0, xSize, 0]}
stroke={_y % 64 ? fineGridLineColor : baseGridLineColor}
strokeWidth={strokeWidth}
listening={false}
/>
);
}
return gridLines;
}, [stageDimensions, stageCoordinates, unscale, gridSpacing]);
return <Group listening={false}>{gridLines}</Group>;
};
export default memo(IAICanvasGrid);

View File

@ -1,26 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { $authToken } from 'app/store/nanostores/authToken';
import type { CanvasImage } from 'features/canvas/store/canvasTypes';
import { memo } from 'react';
import { Image } from 'react-konva';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import useImage from 'use-image';
import IAICanvasImageErrorFallback from './IAICanvasImageErrorFallback';
type IAICanvasImageProps = {
canvasImage: CanvasImage;
};
const IAICanvasImage = (props: IAICanvasImageProps) => {
const { x, y, imageName } = props.canvasImage;
const { currentData: imageDTO, isError } = useGetImageDTOQuery(imageName ?? skipToken);
const [image, status] = useImage(imageDTO?.image_url ?? '', $authToken.get() ? 'use-credentials' : 'anonymous');
if (isError || status === 'failed') {
return <IAICanvasImageErrorFallback canvasImage={props.canvasImage} />;
}
return <Image x={x} y={y} image={image} listening={false} />;
};
export default memo(IAICanvasImage);

View File

@ -1,41 +0,0 @@
import { useToken } from '@invoke-ai/ui-library';
import type { CanvasImage } from 'features/canvas/store/canvasTypes';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Rect, Text } from 'react-konva';
type IAICanvasImageErrorFallbackProps = {
canvasImage: CanvasImage;
};
const IAICanvasImageErrorFallback = ({ canvasImage }: IAICanvasImageErrorFallbackProps) => {
const [rectFill, textFill] = useToken('colors', ['base.500', 'base.900']);
const { t } = useTranslation();
return (
<Group listening={false}>
<Rect
x={canvasImage.x}
y={canvasImage.y}
width={canvasImage.width}
height={canvasImage.height}
fill={rectFill}
listening={false}
/>
<Text
x={canvasImage.x}
y={canvasImage.y}
width={canvasImage.width}
height={canvasImage.height}
align="center"
verticalAlign="middle"
fontFamily='"Inter Variable", sans-serif'
fontSize={canvasImage.width / 16}
fontStyle="600"
text={t('common.imageFailedToLoad')}
fill={textFill}
listening={false}
/>
</Group>
);
};
export default memo(IAICanvasImageErrorFallback);

View File

@ -1,53 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { memo, useEffect, useState } from 'react';
import { Image as KonvaImage } from 'react-konva';
const progressImageSelector = createMemoizedSelector([selectSystemSlice, selectCanvasSlice], (system, canvas) => {
const { denoiseProgress } = system;
const { batchIds } = canvas;
return {
progressImage:
denoiseProgress && batchIds.includes(denoiseProgress.batch_id) ? denoiseProgress.progress_image : undefined,
boundingBox: canvas.layerState.stagingArea.boundingBox,
};
});
const IAICanvasIntermediateImage = () => {
const { progressImage, boundingBox } = useAppSelector(progressImageSelector);
const [loadedImageElement, setLoadedImageElement] = useState<HTMLImageElement | null>(null);
useEffect(() => {
if (!progressImage) {
return;
}
const tempImage = new Image();
tempImage.onload = () => {
setLoadedImageElement(tempImage);
};
tempImage.src = progressImage.dataURL;
}, [progressImage]);
if (!(progressImage && boundingBox) || !loadedImageElement) {
return null;
}
return (
<KonvaImage
x={boundingBox.x}
y={boundingBox.y}
width={boundingBox.width}
height={boundingBox.height}
image={loadedImageElement}
listening={false}
/>
);
};
export default memo(IAICanvasIntermediateImage);

View File

@ -1,92 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { getColoredMaskSVG } from 'features/canvas/util/getColoredMaskSVG';
import type Konva from 'konva';
import type { RectConfig } from 'konva/lib/shapes/Rect';
import { isNumber } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Rect } from 'react-konva';
const canvasMaskCompositerSelector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return {
stageCoordinates: canvas.stageCoordinates,
stageDimensions: canvas.stageDimensions,
};
});
type IAICanvasMaskCompositorProps = RectConfig;
const IAICanvasMaskCompositor = (props: IAICanvasMaskCompositorProps) => {
const { ...rest } = props;
const { stageCoordinates, stageDimensions } = useAppSelector(canvasMaskCompositerSelector);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const maskColorString = useAppSelector((s) => rgbaColorToString(s.canvas.maskColor));
const [fillPatternImage, setFillPatternImage] = useState<HTMLImageElement | null>(null);
const [offset, setOffset] = useState<number>(0);
const rectRef = useRef<Konva.Rect>(null);
const incrementOffset = useCallback(() => {
setOffset(offset + 1);
setTimeout(incrementOffset, 500);
}, [offset]);
useEffect(() => {
if (fillPatternImage) {
return;
}
const image = new Image();
image.onload = () => {
setFillPatternImage(image);
};
image.src = getColoredMaskSVG(maskColorString);
}, [fillPatternImage, maskColorString]);
useEffect(() => {
if (!fillPatternImage) {
return;
}
fillPatternImage.src = getColoredMaskSVG(maskColorString);
}, [fillPatternImage, maskColorString]);
useEffect(() => {
const timer = setInterval(() => setOffset((i) => (i + 1) % 5), 50);
return () => clearInterval(timer);
}, []);
const fillPatternScale = useMemo(() => ({ x: 1 / stageScale, y: 1 / stageScale }), [stageScale]);
if (
!fillPatternImage ||
!isNumber(stageCoordinates.x) ||
!isNumber(stageCoordinates.y) ||
!isNumber(stageScale) ||
!isNumber(stageDimensions.width) ||
!isNumber(stageDimensions.height)
) {
return null;
}
return (
<Rect
ref={rectRef}
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fillPatternImage={fillPatternImage}
fillPatternOffsetY={!isNumber(offset) ? 0 : offset}
fillPatternRepeat="repeat"
fillPatternScale={fillPatternScale}
listening={true}
globalCompositeOperation="source-in"
{...rest}
/>
);
};
export default memo(IAICanvasMaskCompositor);

View File

@ -1,37 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import { isCanvasMaskLine } from 'features/canvas/store/canvasTypes';
import type { GroupConfig } from 'konva/lib/Group';
import { memo } from 'react';
import { Group, Line } from 'react-konva';
type InpaintingCanvasLinesProps = GroupConfig;
/**
* Draws the lines which comprise the mask.
*
* Uses globalCompositeOperation to handle the brush and eraser tools.
*/
const IAICanvasLines = (props: InpaintingCanvasLinesProps) => {
const objects = useAppSelector((s) => s.canvas.layerState.objects);
return (
<Group listening={false} {...props}>
{objects.filter(isCanvasMaskLine).map((line, i) => (
<Line
key={i}
points={line.points}
stroke="rgb(0,0,0)" // The lines can be any color, just need alpha > 0
strokeWidth={line.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={line.tool === 'brush' ? 'source-over' : 'destination-out'}
/>
))}
</Group>
);
};
export default memo(IAICanvasLines);

View File

@ -1,84 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import {
isCanvasBaseImage,
isCanvasBaseLine,
isCanvasEraseRect,
isCanvasFillRect,
} from 'features/canvas/store/canvasTypes';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { memo } from 'react';
import { Group, Line, Rect } from 'react-konva';
import IAICanvasImage from './IAICanvasImage';
const IAICanvasObjectRenderer = () => {
const objects = useAppSelector((s) => s.canvas.layerState.objects);
return (
<Group name="outpainting-objects" listening={false}>
{objects.map((obj, i) => {
if (isCanvasBaseImage(obj)) {
return <IAICanvasImage key={i} canvasImage={obj} />;
} else if (isCanvasBaseLine(obj)) {
const line = (
<Line
key={i}
points={obj.points}
stroke={obj.color ? rgbaColorToString(obj.color) : 'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
strokeWidth={obj.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={obj.tool === 'brush' ? 'source-over' : 'destination-out'}
/>
);
if (obj.clip) {
return (
<Group
key={i}
clipX={obj.clip.x}
clipY={obj.clip.y}
clipWidth={obj.clip.width}
clipHeight={obj.clip.height}
listening={false}
>
{line}
</Group>
);
} else {
return line;
}
} else if (isCanvasFillRect(obj)) {
return (
<Rect
key={i}
x={obj.x}
y={obj.y}
width={obj.width}
height={obj.height}
fill={rgbaColorToString(obj.color)}
listening={false}
/>
);
} else if (isCanvasEraseRect(obj)) {
return (
<Rect
key={i}
x={obj.x}
y={obj.y}
width={obj.width}
height={obj.height}
fill="rgb(255, 255, 255)"
globalCompositeOperation="destination-out"
listening={false}
/>
);
}
})}
</Group>
);
};
export default memo(IAICanvasObjectRenderer);

View File

@ -1,75 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import type { GroupConfig } from 'konva/lib/Group';
import { memo } from 'react';
import { Group, Rect } from 'react-konva';
import IAICanvasImage from './IAICanvasImage';
const dash = [4, 4];
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const {
layerState,
shouldShowStagingImage,
shouldShowStagingOutline,
boundingBoxCoordinates: stageBoundingBoxCoordinates,
boundingBoxDimensions: stageBoundingBoxDimensions,
} = canvas;
const { selectedImageIndex, images, boundingBox } = layerState.stagingArea;
return {
currentStagingAreaImage:
images.length > 0 && selectedImageIndex !== undefined ? images[selectedImageIndex] : undefined,
isOnFirstImage: selectedImageIndex === 0,
isOnLastImage: selectedImageIndex === images.length - 1,
shouldShowStagingImage,
shouldShowStagingOutline,
x: boundingBox?.x ?? stageBoundingBoxCoordinates.x,
y: boundingBox?.y ?? stageBoundingBoxCoordinates.y,
width: boundingBox?.width ?? stageBoundingBoxDimensions.width,
height: boundingBox?.height ?? stageBoundingBoxDimensions.height,
};
});
type Props = GroupConfig;
const IAICanvasStagingArea = (props: Props) => {
const { currentStagingAreaImage, shouldShowStagingImage, shouldShowStagingOutline, x, y, width, height } =
useAppSelector(selector);
return (
<Group {...props}>
{shouldShowStagingImage && currentStagingAreaImage && <IAICanvasImage canvasImage={currentStagingAreaImage} />}
{shouldShowStagingOutline && (
<Group listening={false}>
<Rect
x={x}
y={y}
width={width}
height={height}
strokeWidth={1}
stroke="white"
strokeScaleEnabled={false}
listening={false}
/>
<Rect
x={x}
y={y}
width={width}
height={height}
dash={dash}
strokeWidth={1}
stroke="black"
strokeScaleEnabled={false}
listening={false}
/>
</Group>
)}
</Group>
);
};
export default memo(IAICanvasStagingArea);

View File

@ -1,237 +0,0 @@
import { Button, ButtonGroup, Flex, IconButton } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import {
commitStagingAreaImage,
discardStagedImage,
discardStagedImages,
nextStagingAreaImage,
prevStagingAreaImage,
selectCanvasSlice,
setShouldShowStagingImage,
setShouldShowStagingOutline,
} from 'features/canvas/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiArrowLeftBold,
PiArrowRightBold,
PiCheckBold,
PiEyeBold,
PiEyeSlashBold,
PiFloppyDiskBold,
PiTrashSimpleBold,
PiXBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const {
layerState: {
stagingArea: { images, selectedImageIndex },
},
shouldShowStagingOutline,
shouldShowStagingImage,
} = canvas;
return {
currentIndex: selectedImageIndex,
total: images.length,
currentStagingAreaImage: images.length > 0 ? images[selectedImageIndex] : undefined,
shouldShowStagingImage,
shouldShowStagingOutline,
};
});
const ClearStagingIntermediatesIconButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const totalStagedImages = useAppSelector((s) => s.canvas.layerState.stagingArea.images.length);
const handleDiscardStagingArea = useCallback(() => {
dispatch(discardStagedImages());
}, [dispatch]);
const handleDiscardStagingImage = useCallback(() => {
// Discarding all staged images triggers cancelation of all canvas batches. It's too easy to accidentally
// click the discard button, so to prevent accidental cancelation of all batches, we only discard the current
// image if there are more than one staged images.
if (totalStagedImages > 1) {
dispatch(discardStagedImage());
}
}, [dispatch, totalStagedImages]);
return (
<>
<IconButton
tooltip={`${t('unifiedCanvas.discardCurrent')}`}
aria-label={t('unifiedCanvas.discardCurrent')}
icon={<PiXBold />}
onClick={handleDiscardStagingImage}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={totalStagedImages <= 1}
/>
<IconButton
tooltip={`${t('unifiedCanvas.discardAll')} (Esc)`}
aria-label={t('unifiedCanvas.discardAll')}
icon={<PiTrashSimpleBold />}
onClick={handleDiscardStagingArea}
colorScheme="error"
fontSize={16}
/>
</>
);
};
const IAICanvasStagingAreaToolbar = () => {
const dispatch = useAppDispatch();
const { currentStagingAreaImage, shouldShowStagingImage, currentIndex, total } = useAppSelector(selector);
const { t } = useTranslation();
const handleMouseOver = useCallback(() => {
dispatch(setShouldShowStagingOutline(true));
}, [dispatch]);
const handleMouseOut = useCallback(() => {
dispatch(setShouldShowStagingOutline(false));
}, [dispatch]);
const handlePrevImage = useCallback(() => dispatch(prevStagingAreaImage()), [dispatch]);
const handleNextImage = useCallback(() => dispatch(nextStagingAreaImage()), [dispatch]);
const handleAccept = useCallback(() => dispatch(commitStagingAreaImage()), [dispatch]);
useHotkeys(['left'], handlePrevImage, {
enabled: () => true,
preventDefault: true,
});
useHotkeys(['right'], handleNextImage, {
enabled: () => true,
preventDefault: true,
});
useHotkeys(['enter'], handleAccept, {
enabled: () => true,
preventDefault: true,
});
useHotkeys(
['esc'],
() => {
handleDiscardStagingArea();
},
{
preventDefault: true,
}
);
const { data: imageDTO } = useGetImageDTOQuery(currentStagingAreaImage?.imageName ?? skipToken);
const handleToggleShouldShowStagingImage = useCallback(() => {
dispatch(setShouldShowStagingImage(!shouldShowStagingImage));
}, [dispatch, shouldShowStagingImage]);
const handleSaveToGallery = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(
stagingAreaImageSaved({
imageDTO,
})
);
}, [dispatch, imageDTO]);
useHotkeys(
['shift+s'],
() => {
shouldShowStagingImage && handleSaveToGallery();
},
{
preventDefault: true,
},
[shouldShowStagingImage, handleSaveToGallery]
);
const handleDiscardStagingArea = useCallback(() => {
dispatch(discardStagedImages());
}, [dispatch]);
if (!currentStagingAreaImage) {
return null;
}
return (
<Flex
pos="absolute"
bottom={4}
gap={2}
w="100%"
align="center"
justify="center"
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<IconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}
icon={<PiArrowLeftBold />}
onClick={handlePrevImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
/>
<Button
colorScheme="base"
pointerEvents="none"
isDisabled={!shouldShowStagingImage}
minW={20}
>{`${currentIndex + 1}/${total}`}</Button>
<IconButton
tooltip={`${t('unifiedCanvas.next')} (Right)`}
aria-label={`${t('unifiedCanvas.next')} (Right)`}
icon={<PiArrowRightBold />}
onClick={handleNextImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
/>
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<IconButton
tooltip={`${t('unifiedCanvas.accept')} (Enter)`}
aria-label={`${t('unifiedCanvas.accept')} (Enter)`}
icon={<PiCheckBold />}
onClick={handleAccept}
colorScheme="invokeBlue"
/>
<IconButton
tooltip={shouldShowStagingImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
aria-label={shouldShowStagingImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
data-alert={!shouldShowStagingImage}
icon={shouldShowStagingImage ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={handleToggleShouldShowStagingImage}
colorScheme="invokeBlue"
/>
<IconButton
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<PiFloppyDiskBold />}
onClick={handleSaveToGallery}
colorScheme="invokeBlue"
/>
<ClearStagingIntermediatesIconButton />
</ButtonGroup>
</Flex>
);
};
export default memo(IAICanvasStagingAreaToolbar);

View File

@ -1,120 +0,0 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import roundToHundreth from 'features/canvas/util/roundToHundreth';
import GenerationModeStatusText from 'features/parameters/components/Canvas/GenerationModeStatusText';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import IAICanvasStatusTextCursorPos from './IAICanvasStatusText/IAICanvasStatusTextCursorPos';
const warningColor = 'var(--invoke-colors-warning-500)';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const {
stageDimensions: { width: stageWidth, height: stageHeight },
stageCoordinates: { x: stageX, y: stageY },
boundingBoxDimensions: { width: boxWidth, height: boxHeight },
scaledBoundingBoxDimensions: { width: scaledBoxWidth, height: scaledBoxHeight },
boundingBoxCoordinates: { x: boxX, y: boxY },
stageScale,
shouldShowCanvasDebugInfo,
layer,
boundingBoxScaleMethod,
shouldPreserveMaskedArea,
} = canvas;
let boundingBoxColor = 'inherit';
if (
(boundingBoxScaleMethod === 'none' && (boxWidth < 512 || boxHeight < 512)) ||
(boundingBoxScaleMethod === 'manual' && scaledBoxWidth * scaledBoxHeight < 512 * 512)
) {
boundingBoxColor = warningColor;
}
const activeLayerColor = layer === 'mask' ? warningColor : 'inherit';
return {
activeLayerColor,
layer,
boundingBoxColor,
boundingBoxCoordinatesString: `(${roundToHundreth(boxX)}, ${roundToHundreth(boxY)})`,
boundingBoxDimensionsString: `${boxWidth}×${boxHeight}`,
scaledBoundingBoxDimensionsString: `${scaledBoxWidth}×${scaledBoxHeight}`,
canvasCoordinatesString: `${roundToHundreth(stageX)}×${roundToHundreth(stageY)}`,
canvasDimensionsString: `${stageWidth}×${stageHeight}`,
canvasScaleString: Math.round(stageScale * 100),
shouldShowCanvasDebugInfo,
shouldShowBoundingBox: boundingBoxScaleMethod !== 'auto',
shouldShowScaledBoundingBox: boundingBoxScaleMethod !== 'none',
shouldPreserveMaskedArea,
};
});
const IAICanvasStatusText = () => {
const {
activeLayerColor,
layer,
boundingBoxColor,
boundingBoxCoordinatesString,
boundingBoxDimensionsString,
scaledBoundingBoxDimensionsString,
shouldShowScaledBoundingBox,
canvasCoordinatesString,
canvasDimensionsString,
canvasScaleString,
shouldShowCanvasDebugInfo,
shouldShowBoundingBox,
shouldPreserveMaskedArea,
} = useAppSelector(selector);
const { t } = useTranslation();
return (
<Flex
flexDirection="column"
position="absolute"
top={0}
insetInlineStart={0}
opacity={0.65}
display="flex"
fontSize="sm"
padding={1}
px={2}
minWidth={48}
margin={1}
borderRadius="base"
pointerEvents="none"
bg="base.800"
>
<GenerationModeStatusText />
<Box color={activeLayerColor}>{`${t('unifiedCanvas.activeLayer')}: ${t(`unifiedCanvas.${layer}`)}`}</Box>
<Box>{`${t('unifiedCanvas.canvasScale')}: ${canvasScaleString}%`}</Box>
{shouldPreserveMaskedArea && (
<Box color={warningColor}>
{t('unifiedCanvas.preserveMaskedArea')}: {t('common.on')}
</Box>
)}
{shouldShowBoundingBox && (
<Box color={boundingBoxColor}>{`${t('unifiedCanvas.boundingBox')}: ${boundingBoxDimensionsString}`}</Box>
)}
{shouldShowScaledBoundingBox && (
<Box color={boundingBoxColor}>{`${t(
'unifiedCanvas.scaledBoundingBox'
)}: ${scaledBoundingBoxDimensionsString}`}</Box>
)}
{shouldShowCanvasDebugInfo && (
<>
<Box>{`${t('unifiedCanvas.boundingBoxPosition')}: ${boundingBoxCoordinatesString}`}</Box>
<Box>{`${t('unifiedCanvas.canvasDimensions')}: ${canvasDimensionsString}`}</Box>
<Box>{`${t('unifiedCanvas.canvasPosition')}: ${canvasCoordinatesString}`}</Box>
<IAICanvasStatusTextCursorPos />
</>
)}
</Flex>
);
};
export default memo(IAICanvasStatusText);

View File

@ -1,20 +0,0 @@
import { Box } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $cursorPosition } from 'features/canvas/store/canvasNanostore';
import roundToHundreth from 'features/canvas/util/roundToHundreth';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const IAICanvasStatusTextCursorPos = () => {
const { t } = useTranslation();
const cursorPosition = useStore($cursorPosition);
const cursorCoordinatesString = useMemo(() => {
const x = cursorPosition?.x ?? -1;
const y = cursorPosition?.y ?? -1;
return `(${roundToHundreth(x)}, ${roundToHundreth(y)})`;
}, [cursorPosition?.x, cursorPosition?.y]);
return <Box>{`${t('unifiedCanvas.cursorPosition')}: ${cursorCoordinatesString}`}</Box>;
};
export default memo(IAICanvasStatusTextCursorPos);

View File

@ -1,173 +0,0 @@
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import {
$cursorPosition,
$isMovingBoundingBox,
$isTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { COLOR_PICKER_SIZE, COLOR_PICKER_STROKE_RADIUS } from 'features/canvas/util/constants';
import type { GroupConfig } from 'konva/lib/Group';
import { memo, useMemo } from 'react';
import { Circle, Group } from 'react-konva';
const canvasBrushPreviewSelector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const { stageDimensions, boundingBoxCoordinates, boundingBoxDimensions, shouldRestrictStrokesToBox } = canvas;
const clip = shouldRestrictStrokesToBox
? {
clipX: boundingBoxCoordinates.x,
clipY: boundingBoxCoordinates.y,
clipWidth: boundingBoxDimensions.width,
clipHeight: boundingBoxDimensions.height,
}
: {};
// // big brain time; this is the *inverse* of the clip that is needed for shouldRestrictStrokesToBox
// // it took some fiddling to work out, so I am leaving it here in case it is needed for something else...
// const clipFunc = shouldRestrictStrokesToBox
// ? (ctx: SceneContext) => {
// console.log(
// stageCoordinates.x / stageScale,
// stageCoordinates.y / stageScale,
// stageDimensions.height / stageScale,
// stageDimensions.width / stageScale
// );
// ctx.fillStyle = 'red';
// ctx.rect(
// -stageCoordinates.x / stageScale,
// -stageCoordinates.y / stageScale,
// stageDimensions.width / stageScale,
// stageCoordinates.y / stageScale + boundingBoxCoordinates.y
// );
// ctx.rect(
// -stageCoordinates.x / stageScale,
// boundingBoxCoordinates.y + boundingBoxDimensions.height,
// stageDimensions.width / stageScale,
// stageDimensions.height / stageScale
// );
// ctx.rect(
// -stageCoordinates.x / stageScale,
// -stageCoordinates.y / stageScale,
// stageCoordinates.x / stageScale + boundingBoxCoordinates.x,
// stageDimensions.height / stageScale
// );
// ctx.rect(
// boundingBoxCoordinates.x + boundingBoxDimensions.width,
// -stageCoordinates.y / stageScale,
// stageDimensions.width / stageScale -
// (boundingBoxCoordinates.x + boundingBoxDimensions.width),
// stageDimensions.height / stageScale
// );
// }
// : undefined;
return {
clip,
stageDimensions,
};
});
/**
* Draws a black circle around the canvas brush preview.
*/
const IAICanvasToolPreview = (props: GroupConfig) => {
const radius = useAppSelector((s) => s.canvas.brushSize / 2);
const maskColorString = useAppSelector((s) => rgbaColorToString({ ...s.canvas.maskColor, a: 0.5 }));
const tool = useStore($tool);
const layer = useAppSelector((s) => s.canvas.layer);
const dotRadius = useAppSelector((s) => 1.5 / s.canvas.stageScale);
const strokeWidth = useAppSelector((s) => 1.5 / s.canvas.stageScale);
const brushColorString = useAppSelector((s) => rgbaColorToString(s.canvas.brushColor));
const colorPickerColorString = useAppSelector((s) => rgbaColorToString(s.canvas.colorPickerColor));
const colorPickerInnerRadius = useAppSelector(
(s) => (COLOR_PICKER_SIZE - COLOR_PICKER_STROKE_RADIUS + 1) / s.canvas.stageScale
);
const colorPickerOuterRadius = useAppSelector((s) => COLOR_PICKER_SIZE / s.canvas.stageScale);
const { clip, stageDimensions } = useAppSelector(canvasBrushPreviewSelector);
const cursorPosition = useStore($cursorPosition);
const isMovingBoundingBox = useStore($isMovingBoundingBox);
const isTransformingBoundingBox = useStore($isTransformingBoundingBox);
const brushX = useMemo<number>(
() => (cursorPosition ? cursorPosition.x : stageDimensions.width / 2),
[cursorPosition, stageDimensions]
);
const brushY = useMemo<number>(
() => (cursorPosition ? cursorPosition.y : stageDimensions.height / 2),
[cursorPosition, stageDimensions]
);
const shouldDrawBrushPreview = useMemo(
() => !(isMovingBoundingBox || isTransformingBoundingBox || !cursorPosition),
[cursorPosition, isMovingBoundingBox, isTransformingBoundingBox]
);
if (!shouldDrawBrushPreview) {
return null;
}
return (
<Group listening={false} {...clip} {...props}>
{tool === 'colorPicker' ? (
<>
<Circle
x={brushX}
y={brushY}
radius={colorPickerOuterRadius}
stroke={brushColorString}
strokeWidth={COLOR_PICKER_STROKE_RADIUS}
strokeScaleEnabled={false}
listening={false}
/>
<Circle
x={brushX}
y={brushY}
radius={colorPickerInnerRadius}
stroke={colorPickerColorString}
strokeWidth={COLOR_PICKER_STROKE_RADIUS}
strokeScaleEnabled={false}
listening={false}
/>
</>
) : (
<>
<Circle
x={brushX}
y={brushY}
radius={radius}
fill={layer === 'mask' ? maskColorString : brushColorString}
globalCompositeOperation={tool === 'eraser' ? 'destination-out' : 'source-out'}
listening={false}
/>
<Circle
x={brushX}
y={brushY}
radius={radius}
stroke="rgba(255,255,255,0.4)"
strokeWidth={strokeWidth * 2}
strokeEnabled={true}
listening={false}
/>
<Circle
x={brushX}
y={brushY}
radius={radius}
stroke="rgba(0,0,0,1)"
strokeWidth={strokeWidth}
strokeEnabled={true}
listening={false}
/>
</>
)}
<Circle x={brushX} y={brushY} radius={dotRadius * 2} fill="rgba(255,255,255,0.4)" listening={false} />
<Circle x={brushX} y={brushY} radius={dotRadius} fill="rgba(0,0,0,1)" listening={false} />
</Group>
);
};
export default memo(IAICanvasToolPreview);

View File

@ -1,331 +0,0 @@
import { useShiftModifier } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { roundDownToMultiple, roundDownToMultipleMin, roundToMultiple } from 'common/util/roundDownToMultiple';
import {
$isDrawing,
$isMouseOverBoundingBox,
$isMouseOverBoundingBoxOutline,
$isMovingBoundingBox,
$isTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import {
aspectRatioChanged,
setBoundingBoxCoordinates,
setBoundingBoxDimensions,
setShouldSnapToGrid,
} from 'features/canvas/store/canvasSlice';
import { CANVAS_GRID_SIZE_COARSE, CANVAS_GRID_SIZE_FINE } from 'features/canvas/store/constants';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import type Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Group, Rect, Transformer } from 'react-konva';
const borderDash = [4, 4];
type IAICanvasBoundingBoxPreviewProps = GroupConfig;
const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => {
const { ...rest } = props;
const dispatch = useAppDispatch();
const boundingBoxCoordinates = useAppSelector((s) => s.canvas.boundingBoxCoordinates);
const boundingBoxDimensions = useAppSelector((s) => s.canvas.boundingBoxDimensions);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const shouldSnapToGrid = useAppSelector((s) => s.canvas.shouldSnapToGrid);
const hitStrokeWidth = useAppSelector((s) => 20 / s.canvas.stageScale);
const aspectRatio = useAppSelector((s) => s.canvas.aspectRatio);
const optimalDimension = useAppSelector(selectOptimalDimension);
const transformerRef = useRef<Konva.Transformer>(null);
const shapeRef = useRef<Konva.Rect>(null);
const shift = useShiftModifier();
const tool = useStore($tool);
const isDrawing = useStore($isDrawing);
const isMovingBoundingBox = useStore($isMovingBoundingBox);
const isTransformingBoundingBox = useStore($isTransformingBoundingBox);
const isMouseOverBoundingBoxOutline = useStore($isMouseOverBoundingBoxOutline);
useEffect(() => {
if (!transformerRef.current || !shapeRef.current) {
return;
}
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}, []);
const gridSize = useMemo(() => (shift ? CANVAS_GRID_SIZE_FINE : CANVAS_GRID_SIZE_COARSE), [shift]);
const scaledStep = useMemo(() => gridSize * stageScale, [gridSize, stageScale]);
useHotkeys(
'N',
() => {
dispatch(setShouldSnapToGrid(!shouldSnapToGrid));
},
[shouldSnapToGrid]
);
const handleOnDragMove = useCallback(
(e: KonvaEventObject<DragEvent>) => {
if (!shouldSnapToGrid) {
dispatch(
setBoundingBoxCoordinates({
x: Math.floor(e.target.x()),
y: Math.floor(e.target.y()),
})
);
return;
}
const dragX = e.target.x();
const dragY = e.target.y();
const newX = roundToMultiple(dragX, gridSize);
const newY = roundToMultiple(dragY, gridSize);
e.target.x(newX);
e.target.y(newY);
dispatch(
setBoundingBoxCoordinates({
x: newX,
y: newY,
})
);
},
[dispatch, gridSize, shouldSnapToGrid]
);
const handleOnTransform = useCallback(
(_e: KonvaEventObject<Event>) => {
/**
* The Konva Transformer changes the object's anchor point and scale factor,
* not its width and height. We need to un-scale the width and height before
* setting the values.
*/
if (!shapeRef.current) {
return;
}
const rect = shapeRef.current;
const scaleX = rect.scaleX();
const scaleY = rect.scaleY();
// undo the scaling
const width = Math.round(rect.width() * scaleX);
const height = Math.round(rect.height() * scaleY);
const x = Math.round(rect.x());
const y = Math.round(rect.y());
if (aspectRatio.isLocked) {
const newDimensions = calculateNewSize(aspectRatio.value, width * height);
dispatch(
setBoundingBoxDimensions(
{
width: roundDownToMultipleMin(newDimensions.width, gridSize),
height: roundDownToMultipleMin(newDimensions.height, gridSize),
},
optimalDimension
)
);
} else {
dispatch(
setBoundingBoxDimensions(
{
width: roundDownToMultipleMin(width, gridSize),
height: roundDownToMultipleMin(height, gridSize),
},
optimalDimension
)
);
dispatch(
aspectRatioChanged({
isLocked: false,
id: 'Free',
value: width / height,
})
);
}
dispatch(
setBoundingBoxCoordinates({
x: shouldSnapToGrid ? roundDownToMultiple(x, gridSize) : x,
y: shouldSnapToGrid ? roundDownToMultiple(y, gridSize) : y,
})
);
// Reset the scale now that the coords/dimensions have been un-scaled
rect.scaleX(1);
rect.scaleY(1);
},
[aspectRatio.isLocked, aspectRatio.value, dispatch, shouldSnapToGrid, gridSize, optimalDimension]
);
const anchorDragBoundFunc = useCallback(
(
oldPos: Vector2d, // old absolute position of anchor point
newPos: Vector2d, // new absolute position (potentially) of anchor point
_e: MouseEvent
) => {
/**
* Konva does not transform with width or height. It transforms the anchor point
* and scale factor. This is then sent to the shape's onTransform listeners.
*
* We need to snap the new dimensions to steps of 8 (or 64). But because the whole
* stage is scaled, our actual desired step is actually 8 (or 64) * the stage scale.
*
* Additionally, we need to ensure we offset the position so that we snap to a
* multiple of 8 (or 64) that is aligned with the grid, and not from the absolute zero
* coordinate.
*/
// Calculate the offset of the grid.
const offsetX = oldPos.x % scaledStep;
const offsetY = oldPos.y % scaledStep;
const newCoordinates = {
x: roundDownToMultiple(newPos.x, scaledStep) + offsetX,
y: roundDownToMultiple(newPos.y, scaledStep) + offsetY,
};
return newCoordinates;
},
[scaledStep]
);
const handleStartedTransforming = useCallback(() => {
$isTransformingBoundingBox.set(true);
}, []);
const handleEndedTransforming = useCallback(() => {
$isTransformingBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMouseOverBoundingBoxOutline.set(false);
}, []);
const handleStartedMoving = useCallback(() => {
$isMovingBoundingBox.set(true);
}, []);
const handleEndedModifying = useCallback(() => {
$isTransformingBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMouseOverBoundingBoxOutline.set(false);
}, []);
const handleMouseOver = useCallback(() => {
$isMouseOverBoundingBoxOutline.set(true);
}, []);
const handleMouseOut = useCallback(() => {
!isTransformingBoundingBox && !isMovingBoundingBox && $isMouseOverBoundingBoxOutline.set(false);
}, [isMovingBoundingBox, isTransformingBoundingBox]);
const handleMouseEnterBoundingBox = useCallback(() => {
$isMouseOverBoundingBox.set(true);
}, []);
const handleMouseLeaveBoundingBox = useCallback(() => {
$isMouseOverBoundingBox.set(false);
}, []);
const stroke = useMemo(() => {
if (isMouseOverBoundingBoxOutline || isMovingBoundingBox || isTransformingBoundingBox) {
return 'rgba(255,255,255,0.5)';
}
return 'white';
}, [isMouseOverBoundingBoxOutline, isMovingBoundingBox, isTransformingBoundingBox]);
const strokeWidth = useMemo(() => {
if (isMouseOverBoundingBoxOutline || isMovingBoundingBox || isTransformingBoundingBox) {
return 6 / stageScale;
}
return 1 / stageScale;
}, [isMouseOverBoundingBoxOutline, isMovingBoundingBox, isTransformingBoundingBox, stageScale]);
const enabledAnchors = useMemo(() => {
if (tool !== 'move') {
return emptyArray;
}
if (aspectRatio.isLocked) {
// TODO: The math to resize the bbox when locked and using other handles is confusing.
// Workaround for now is to only allow resizing from the bottom-right handle.
return ['bottom-right'];
}
return undefined;
}, [aspectRatio.isLocked, tool]);
return (
<Group {...rest}>
<Rect
height={boundingBoxDimensions.height}
width={boundingBoxDimensions.width}
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
onMouseEnter={handleMouseEnterBoundingBox}
onMouseOver={handleMouseEnterBoundingBox}
onMouseLeave={handleMouseLeaveBoundingBox}
onMouseOut={handleMouseLeaveBoundingBox}
/>
<Rect
draggable={true}
fillEnabled={false}
height={boundingBoxDimensions.height}
hitStrokeWidth={hitStrokeWidth}
listening={!isDrawing && tool === 'move'}
onDragStart={handleStartedMoving}
onDragEnd={handleEndedModifying}
onDragMove={handleOnDragMove}
onMouseDown={handleStartedMoving}
onMouseOut={handleMouseOut}
onMouseOver={handleMouseOver}
onMouseEnter={handleMouseOver}
onMouseUp={handleEndedModifying}
onTransform={handleOnTransform}
onTransformEnd={handleEndedTransforming}
ref={shapeRef}
stroke={stroke}
strokeWidth={strokeWidth}
width={boundingBoxDimensions.width}
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
/>
<Transformer
anchorCornerRadius={3}
anchorDragBoundFunc={anchorDragBoundFunc}
anchorFill="rgba(212,216,234,1)"
anchorSize={15}
anchorStroke="rgb(42,42,42)"
borderDash={borderDash}
borderEnabled={true}
borderStroke="black"
draggable={false}
enabledAnchors={enabledAnchors}
flipEnabled={false}
ignoreStroke={true}
keepRatio={false}
listening={!isDrawing && tool === 'move'}
onDragStart={handleStartedMoving}
onDragEnd={handleEndedModifying}
onMouseDown={handleStartedTransforming}
onMouseUp={handleEndedTransforming}
onTransformEnd={handleEndedTransforming}
ref={transformerRef}
rotateEnabled={false}
shiftBehavior="none"
/>
</Group>
);
};
export default memo(IAICanvasBoundingBox);
const emptyArray: string[] = [];

View File

@ -1,156 +0,0 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import {
Box,
Button,
ButtonGroup,
Checkbox,
Flex,
FormControl,
FormControlGroup,
FormLabel,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
import { canvasMaskSavedToGallery } from 'features/canvas/store/actions';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import {
clearMask,
setIsMaskEnabled,
setLayer,
setMaskColor,
setShouldPreserveMaskedArea,
} from 'features/canvas/store/canvasSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiExcludeBold, PiFloppyDiskBackFill, PiTrashSimpleFill } from 'react-icons/pi';
const formLabelProps: FormLabelProps = {
flexGrow: 1,
};
const IAICanvasMaskOptions = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const layer = useAppSelector((s) => s.canvas.layer);
const maskColor = useAppSelector((s) => s.canvas.maskColor);
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const shouldPreserveMaskedArea = useAppSelector((s) => s.canvas.shouldPreserveMaskedArea);
const isStaging = useAppSelector(isStagingSelector);
useHotkeys(
['q'],
() => {
handleToggleMaskLayer();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[layer]
);
useHotkeys(
['shift+c'],
() => {
handleClearMask();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
useHotkeys(
['h'],
() => {
handleToggleEnableMask();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[isMaskEnabled]
);
const handleToggleMaskLayer = useCallback(() => {
dispatch(setLayer(layer === 'mask' ? 'base' : 'mask'));
}, [dispatch, layer]);
const handleClearMask = useCallback(() => {
dispatch(clearMask());
}, [dispatch]);
const handleToggleEnableMask = useCallback(() => {
dispatch(setIsMaskEnabled(!isMaskEnabled));
}, [dispatch, isMaskEnabled]);
const handleSaveMask = useCallback(async () => {
dispatch(canvasMaskSavedToGallery());
}, [dispatch]);
const handleChangePreserveMaskedArea = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(setShouldPreserveMaskedArea(e.target.checked));
},
[dispatch]
);
const handleChangeMaskColor = useCallback(
(newColor: RgbaColor) => {
dispatch(setMaskColor(newColor));
},
[dispatch]
);
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton
aria-label={t('unifiedCanvas.maskingOptions')}
tooltip={t('unifiedCanvas.maskingOptions')}
icon={<PiExcludeBold />}
isChecked={layer === 'mask'}
isDisabled={isStaging}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControlGroup formLabelProps={formLabelProps}>
<FormControl>
<FormLabel>{`${t('unifiedCanvas.enableMask')} (H)`}</FormLabel>
<Checkbox isChecked={isMaskEnabled} onChange={handleToggleEnableMask} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.preserveMaskedArea')}</FormLabel>
<Checkbox isChecked={shouldPreserveMaskedArea} onChange={handleChangePreserveMaskedArea} />
</FormControl>
</FormControlGroup>
<Box pt={2} pb={2}>
<IAIColorPicker color={maskColor} onChange={handleChangeMaskColor} />
</Box>
<ButtonGroup isAttached={false}>
<Button size="sm" leftIcon={<PiFloppyDiskBackFill />} onClick={handleSaveMask}>
{t('unifiedCanvas.saveMask')}
</Button>
<Button size="sm" leftIcon={<PiTrashSimpleFill />} onClick={handleClearMask}>
{t('unifiedCanvas.clearMask')}
</Button>
</ButtonGroup>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(IAICanvasMaskOptions);

View File

@ -1,44 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { redo } from 'features/canvas/store/canvasSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowClockwiseBold } from 'react-icons/pi';
const IAICanvasRedoButton = () => {
const dispatch = useAppDispatch();
const canRedo = useAppSelector((s) => s.canvas.futureLayerStates.length > 0);
const activeTabName = useAppSelector(activeTabNameSelector);
const { t } = useTranslation();
const handleRedo = useCallback(() => {
dispatch(redo());
}, [dispatch]);
useHotkeys(
['meta+shift+z', 'ctrl+shift+z', 'control+y', 'meta+y'],
() => {
handleRedo();
},
{
enabled: () => canRedo,
preventDefault: true,
},
[activeTabName, canRedo]
);
return (
<IconButton
aria-label={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
tooltip={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
icon={<PiArrowClockwiseBold />}
onClick={handleRedo}
isDisabled={!canRedo}
/>
);
};
export default memo(IAICanvasRedoButton);

View File

@ -1,187 +0,0 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import {
Checkbox,
Flex,
FormControl,
FormControlGroup,
FormLabel,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ClearCanvasHistoryButtonModal from 'features/canvas/components/ClearCanvasHistoryButtonModal';
import {
setShouldAntialias,
setShouldAutoSave,
setShouldCropToBoundingBoxOnSave,
setShouldDarkenOutsideBoundingBox,
setShouldFitImageSize,
setShouldInvertBrushSizeScrollDirection,
setShouldRestrictStrokesToBox,
setShouldShowCanvasDebugInfo,
setShouldShowGrid,
setShouldShowIntermediates,
setShouldSnapToGrid,
} from 'features/canvas/store/canvasSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiGearSixBold } from 'react-icons/pi';
const formLabelProps: FormLabelProps = {
flexGrow: 1,
};
const IAICanvasSettingsButtonPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const shouldAutoSave = useAppSelector((s) => s.canvas.shouldAutoSave);
const shouldCropToBoundingBoxOnSave = useAppSelector((s) => s.canvas.shouldCropToBoundingBoxOnSave);
const shouldDarkenOutsideBoundingBox = useAppSelector((s) => s.canvas.shouldDarkenOutsideBoundingBox);
const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
const shouldShowCanvasDebugInfo = useAppSelector((s) => s.canvas.shouldShowCanvasDebugInfo);
const shouldShowGrid = useAppSelector((s) => s.canvas.shouldShowGrid);
const shouldShowIntermediates = useAppSelector((s) => s.canvas.shouldShowIntermediates);
const shouldSnapToGrid = useAppSelector((s) => s.canvas.shouldSnapToGrid);
const shouldRestrictStrokesToBox = useAppSelector((s) => s.canvas.shouldRestrictStrokesToBox);
const shouldAntialias = useAppSelector((s) => s.canvas.shouldAntialias);
const shouldFitImageSize = useAppSelector((s) => s.canvas.shouldFitImageSize);
useHotkeys(
['n'],
() => {
dispatch(setShouldSnapToGrid(!shouldSnapToGrid));
},
{
enabled: true,
preventDefault: true,
},
[shouldSnapToGrid]
);
const handleChangeShouldSnapToGrid = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldSnapToGrid(e.target.checked)),
[dispatch]
);
const handleChangeShouldShowIntermediates = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldShowIntermediates(e.target.checked)),
[dispatch]
);
const handleChangeShouldShowGrid = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldShowGrid(e.target.checked)),
[dispatch]
);
const handleChangeShouldDarkenOutsideBoundingBox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldDarkenOutsideBoundingBox(e.target.checked)),
[dispatch]
);
const handleChangeShouldInvertBrushSizeScrollDirection = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldInvertBrushSizeScrollDirection(e.target.checked)),
[dispatch]
);
const handleChangeShouldAutoSave = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldAutoSave(e.target.checked)),
[dispatch]
);
const handleChangeShouldCropToBoundingBoxOnSave = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldCropToBoundingBoxOnSave(e.target.checked)),
[dispatch]
);
const handleChangeShouldRestrictStrokesToBox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldRestrictStrokesToBox(e.target.checked)),
[dispatch]
);
const handleChangeShouldShowCanvasDebugInfo = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldShowCanvasDebugInfo(e.target.checked)),
[dispatch]
);
const handleChangeShouldAntialias = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldAntialias(e.target.checked)),
[dispatch]
);
const handleChangeShouldFitImageSize = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldFitImageSize(e.target.checked)),
[dispatch]
);
return (
<Popover>
<PopoverTrigger>
<IconButton
tooltip={t('unifiedCanvas.canvasSettings')}
aria-label={t('unifiedCanvas.canvasSettings')}
icon={<PiGearSixBold />}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControlGroup formLabelProps={formLabelProps}>
<FormControl>
<FormLabel>{t('unifiedCanvas.showIntermediates')}</FormLabel>
<Checkbox isChecked={shouldShowIntermediates} onChange={handleChangeShouldShowIntermediates} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.showGrid')}</FormLabel>
<Checkbox isChecked={shouldShowGrid} onChange={handleChangeShouldShowGrid} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.snapToGrid')}</FormLabel>
<Checkbox isChecked={shouldSnapToGrid} onChange={handleChangeShouldSnapToGrid} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.darkenOutsideSelection')}</FormLabel>
<Checkbox
isChecked={shouldDarkenOutsideBoundingBox}
onChange={handleChangeShouldDarkenOutsideBoundingBox}
/>
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.autoSaveToGallery')}</FormLabel>
<Checkbox isChecked={shouldAutoSave} onChange={handleChangeShouldAutoSave} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.saveBoxRegionOnly')}</FormLabel>
<Checkbox
isChecked={shouldCropToBoundingBoxOnSave}
onChange={handleChangeShouldCropToBoundingBoxOnSave}
/>
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.limitStrokesToBox')}</FormLabel>
<Checkbox isChecked={shouldRestrictStrokesToBox} onChange={handleChangeShouldRestrictStrokesToBox} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.invertBrushSizeScrollDirection')}</FormLabel>
<Checkbox
isChecked={shouldInvertBrushSizeScrollDirection}
onChange={handleChangeShouldInvertBrushSizeScrollDirection}
/>
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.showCanvasDebugInfo')}</FormLabel>
<Checkbox isChecked={shouldShowCanvasDebugInfo} onChange={handleChangeShouldShowCanvasDebugInfo} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.antialiasing')}</FormLabel>
<Checkbox isChecked={shouldAntialias} onChange={handleChangeShouldAntialias} />
</FormControl>
<FormControl>
<FormLabel>{t('unifiedCanvas.initialFitImageSize')}</FormLabel>
<Checkbox isChecked={shouldFitImageSize} onChange={handleChangeShouldFitImageSize} />
</FormControl>
</FormControlGroup>
<ClearCanvasHistoryButtonModal />
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default memo(IAICanvasSettingsButtonPopover);

View File

@ -1,280 +0,0 @@
import {
Box,
ButtonGroup,
CompositeNumberInput,
CompositeSlider,
Flex,
FormControl,
FormLabel,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
import { $tool, resetToolInteractionState } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addEraseRect, addFillRect, setBrushColor, setBrushSize } from 'features/canvas/store/canvasSlice';
import { clamp } from 'lodash-es';
import { memo, useCallback } from 'react';
import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiEraserBold,
PiEyedropperBold,
PiPaintBrushBold,
PiPaintBucketBold,
PiSlidersHorizontalBold,
PiXBold,
} from 'react-icons/pi';
const marks = [1, 25, 50, 75, 100];
const IAICanvasToolChooserOptions = () => {
const dispatch = useAppDispatch();
const tool = useStore($tool);
const brushColor = useAppSelector((s) => s.canvas.brushColor);
const brushSize = useAppSelector((s) => s.canvas.brushSize);
const isStaging = useAppSelector(isStagingSelector);
const { t } = useTranslation();
useHotkeys(
['b'],
() => {
handleSelectBrushTool();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
useHotkeys(
['e'],
() => {
handleSelectEraserTool();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[tool]
);
useHotkeys(
['c'],
() => {
handleSelectColorPickerTool();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[tool]
);
useHotkeys(
['shift+f'],
() => {
handleFillRect();
},
{
enabled: () => !isStaging,
preventDefault: true,
}
);
useHotkeys(
['delete', 'backspace'],
() => {
handleEraseBoundingBox();
},
{
enabled: () => !isStaging,
preventDefault: true,
}
);
useHotkeys(
['BracketLeft'],
() => {
if (brushSize - 5 <= 5) {
dispatch(setBrushSize(Math.max(brushSize - 1, 1)));
} else {
dispatch(setBrushSize(Math.max(brushSize - 5, 1)));
}
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[brushSize]
);
useHotkeys(
['BracketRight'],
() => {
dispatch(setBrushSize(Math.min(brushSize + 5, 500)));
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[brushSize]
);
useHotkeys(
['Shift+BracketLeft'],
() => {
dispatch(
setBrushColor({
...brushColor,
a: clamp(brushColor.a - 0.05, 0.05, 1),
})
);
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[brushColor]
);
useHotkeys(
['Shift+BracketRight'],
() => {
dispatch(
setBrushColor({
...brushColor,
a: clamp(brushColor.a + 0.05, 0.05, 1),
})
);
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[brushColor]
);
const handleSelectBrushTool = useCallback(() => {
$tool.set('brush');
resetToolInteractionState();
}, []);
const handleSelectEraserTool = useCallback(() => {
$tool.set('eraser');
resetToolInteractionState();
}, []);
const handleSelectColorPickerTool = useCallback(() => {
$tool.set('colorPicker');
resetToolInteractionState();
}, []);
const handleFillRect = useCallback(() => {
dispatch(addFillRect());
}, [dispatch]);
const handleEraseBoundingBox = useCallback(() => {
dispatch(addEraseRect());
}, [dispatch]);
const handleChangeBrushSize = useCallback(
(newSize: number) => {
dispatch(setBrushSize(newSize));
},
[dispatch]
);
const handleChangeBrushColor = useCallback(
(newColor: RgbaColor) => {
dispatch(setBrushColor(newColor));
},
[dispatch]
);
return (
<ButtonGroup>
<IconButton
aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<PiPaintBrushBold />}
isChecked={tool === 'brush' && !isStaging}
onClick={handleSelectBrushTool}
isDisabled={isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<PiEraserBold />}
isChecked={tool === 'eraser' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectEraserTool}
/>
<IconButton
aria-label={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
tooltip={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
icon={<PiPaintBucketBold />}
isDisabled={isStaging}
onClick={handleFillRect}
/>
<IconButton
aria-label={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
tooltip={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
icon={<PiXBold />}
isDisabled={isStaging}
onClick={handleEraseBoundingBox}
/>
<IconButton
aria-label={`${t('unifiedCanvas.colorPicker')} (C)`}
tooltip={`${t('unifiedCanvas.colorPicker')} (C)`}
icon={<PiEyedropperBold />}
isChecked={tool === 'colorPicker' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectColorPickerTool}
/>
<Popover>
<PopoverTrigger>
<IconButton
aria-label={t('unifiedCanvas.brushOptions')}
tooltip={t('unifiedCanvas.brushOptions')}
icon={<PiSlidersHorizontalBold />}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody>
<Flex minWidth={60} direction="column" gap={4} width="100%">
<Flex gap={4} justifyContent="space-between">
<FormControl>
<FormLabel>{t('unifiedCanvas.brushSize')}</FormLabel>
<CompositeSlider
value={brushSize}
min={1}
max={100}
step={1}
onChange={handleChangeBrushSize}
marks={marks}
defaultValue={50}
/>
<CompositeNumberInput
value={brushSize}
min={1}
max={500}
step={1}
onChange={handleChangeBrushSize}
defaultValue={50}
/>
</FormControl>
</Flex>
<Box w="full" pt={2} pb={2}>
<IAIColorPicker color={brushColor} onChange={handleChangeBrushColor} withNumberInput />
</Box>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</ButtonGroup>
);
};
export default memo(IAICanvasToolChooserOptions);

View File

@ -1,323 +0,0 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { ButtonGroup, Combobox, Flex, FormControl, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick';
import {
canvasCopiedToClipboard,
canvasDownloadedAsImage,
canvasMerged,
canvasSavedToGallery,
} from 'features/canvas/store/actions';
import { $canvasBaseLayer, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import {
resetCanvas,
resetCanvasView,
setIsMaskEnabled,
setLayer,
setShouldShowBoundingBox,
} from 'features/canvas/store/canvasSlice';
import type { CanvasLayer } from 'features/canvas/store/canvasTypes';
import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiCopyBold,
PiCrosshairSimpleBold,
PiDownloadSimpleBold,
PiEyeBold,
PiEyeSlashBold,
PiFloppyDiskBold,
PiHandGrabbingBold,
PiStackBold,
PiTrashSimpleBold,
PiUploadSimpleBold,
} from 'react-icons/pi';
import IAICanvasMaskOptions from './IAICanvasMaskOptions';
import IAICanvasRedoButton from './IAICanvasRedoButton';
import IAICanvasSettingsButtonPopover from './IAICanvasSettingsButtonPopover';
import IAICanvasToolChooserOptions from './IAICanvasToolChooserOptions';
import IAICanvasUndoButton from './IAICanvasUndoButton';
const IAICanvasToolbar = () => {
const dispatch = useAppDispatch();
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const layer = useAppSelector((s) => s.canvas.layer);
const tool = useStore($tool);
const isStaging = useAppSelector(isStagingSelector);
const { t } = useTranslation();
const { isClipboardAPIAvailable } = useCopyImageToClipboard();
const shouldShowBoundingBox = useAppSelector((s) => s.canvas.shouldShowBoundingBox);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
useHotkeys(
['v'],
() => {
handleSelectMoveTool();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
useHotkeys(
'shift+h',
() => {
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[shouldShowBoundingBox]
);
useHotkeys(
['r'],
() => {
handleResetCanvasView();
},
{
enabled: () => true,
preventDefault: true,
},
[]
);
useHotkeys(
['shift+m'],
() => {
handleMergeVisible();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
useHotkeys(
['shift+s'],
() => {
!isStaging && handleSaveToGallery();
},
{
enabled: true,
preventDefault: true,
},
[isStaging]
);
useHotkeys(
['meta+c', 'ctrl+c'],
() => {
handleCopyImageToClipboard();
},
{
enabled: () => !isStaging && isClipboardAPIAvailable,
preventDefault: true,
},
[isClipboardAPIAvailable]
);
useHotkeys(
['shift+d'],
() => {
handleDownloadAsImage();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
const handleSelectMoveTool = useCallback(() => {
$tool.set('move');
}, []);
const handleSetShouldShowBoundingBox = useCallback(() => {
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
}, [dispatch, shouldShowBoundingBox]);
const handleResetCanvasView = useCallback(
(shouldScaleTo1 = false) => {
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
return;
}
const clientRect = canvasBaseLayer.getClientRect({
skipTransform: true,
});
dispatch(
resetCanvasView({
contentRect: clientRect,
shouldScaleTo1,
})
);
},
[dispatch]
);
const onSingleClick = useCallback(() => {
handleResetCanvasView(false);
}, [handleResetCanvasView]);
const onDoubleClick = useCallback(() => {
handleResetCanvasView(true);
}, [handleResetCanvasView]);
const handleClickResetCanvasView = useSingleAndDoubleClick({
onSingleClick,
onDoubleClick,
});
const handleResetCanvas = useCallback(() => {
dispatch(resetCanvas());
}, [dispatch]);
const handleMergeVisible = useCallback(() => {
dispatch(canvasMerged());
}, [dispatch]);
const handleSaveToGallery = useCallback(() => {
dispatch(canvasSavedToGallery());
}, [dispatch]);
const handleCopyImageToClipboard = useCallback(() => {
if (!isClipboardAPIAvailable) {
return;
}
dispatch(canvasCopiedToClipboard());
}, [dispatch, isClipboardAPIAvailable]);
const handleDownloadAsImage = useCallback(() => {
dispatch(canvasDownloadedAsImage());
}, [dispatch]);
const handleChangeLayer = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
return;
}
dispatch(setLayer(v.value as CanvasLayer));
if (v.value === 'mask' && !isMaskEnabled) {
dispatch(setIsMaskEnabled(true));
}
},
[dispatch, isMaskEnabled]
);
const layerOptions = useMemo<{ label: string; value: CanvasLayer }[]>(
() => [
{ label: t('unifiedCanvas.base'), value: 'base' },
{ label: t('unifiedCanvas.mask'), value: 'mask' },
],
[t]
);
const layerValue = useMemo(() => layerOptions.filter((o) => o.value === layer)[0] ?? null, [layer, layerOptions]);
return (
<Flex alignItems="center" gap={2} flexWrap="wrap">
<Tooltip label={`${t('unifiedCanvas.layer')} (Q)`}>
<FormControl isDisabled={isStaging} w="5rem">
<Combobox value={layerValue} options={layerOptions} onChange={handleChangeLayer} />
</FormControl>
</Tooltip>
<IAICanvasMaskOptions />
<IAICanvasToolChooserOptions />
<ButtonGroup>
<IconButton
aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiHandGrabbingBold />}
isChecked={tool === 'move' || isStaging}
onClick={handleSelectMoveTool}
/>
<IconButton
aria-label={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
tooltip={`${shouldShowBoundingBox ? t('unifiedCanvas.hideBoundingBox') : t('unifiedCanvas.showBoundingBox')} (Shift + H)`}
icon={shouldShowBoundingBox ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={handleSetShouldShowBoundingBox}
isDisabled={isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.resetView')} (R)`}
tooltip={`${t('unifiedCanvas.resetView')} (R)`}
icon={<PiCrosshairSimpleBold />}
onClick={handleClickResetCanvasView}
/>
</ButtonGroup>
<ButtonGroup>
<IconButton
aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
icon={<PiStackBold />}
onClick={handleMergeVisible}
isDisabled={isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
icon={<PiFloppyDiskBold />}
onClick={handleSaveToGallery}
isDisabled={isStaging}
/>
{isClipboardAPIAvailable && (
<IconButton
aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
icon={<PiCopyBold />}
onClick={handleCopyImageToClipboard}
isDisabled={isStaging}
/>
)}
<IconButton
aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
icon={<PiDownloadSimpleBold />}
onClick={handleDownloadAsImage}
isDisabled={isStaging}
/>
</ButtonGroup>
<ButtonGroup>
<IAICanvasUndoButton />
<IAICanvasRedoButton />
</ButtonGroup>
<ButtonGroup>
<IconButton
aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`}
icon={<PiUploadSimpleBold />}
isDisabled={isStaging}
{...getUploadButtonProps()}
/>
<input {...getUploadInputProps()} />
<IconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`}
icon={<PiTrashSimpleBold />}
onClick={handleResetCanvas}
colorScheme="error"
isDisabled={isStaging}
/>
</ButtonGroup>
<ButtonGroup>
<IAICanvasSettingsButtonPopover />
</ButtonGroup>
</Flex>
);
};
export default memo(IAICanvasToolbar);

View File

@ -1,43 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { undo } from 'features/canvas/store/canvasSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
const IAICanvasUndoButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const activeTabName = useAppSelector(activeTabNameSelector);
const canUndo = useAppSelector((s) => s.canvas.pastLayerStates.length > 0);
const handleUndo = useCallback(() => {
dispatch(undo());
}, [dispatch]);
useHotkeys(
['meta+z', 'ctrl+z'],
() => {
handleUndo();
},
{
enabled: () => canUndo,
preventDefault: true,
},
[activeTabName, canUndo]
);
return (
<IconButton
aria-label={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
tooltip={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
icon={<PiArrowCounterClockwiseBold />}
onClick={handleUndo}
isDisabled={!canUndo}
/>
);
};
export default memo(IAICanvasUndoButton);

View File

@ -1,46 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isMovingBoundingBox, $isMovingStage, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { setStageCoordinates } from 'features/canvas/store/canvasSlice';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useCallback } from 'react';
const useCanvasDrag = () => {
const dispatch = useAppDispatch();
const isStaging = useAppSelector(isStagingSelector);
const handleDragStart = useCallback(() => {
if (!(($tool.get() === 'move' || isStaging) && !$isMovingBoundingBox.get())) {
return;
}
$isMovingStage.set(true);
}, [isStaging]);
const handleDragMove = useCallback(
(e: KonvaEventObject<MouseEvent>) => {
const tool = $tool.get();
if (!((tool === 'move' || isStaging) && !$isMovingBoundingBox.get())) {
return;
}
const newCoordinates = { x: e.target.x(), y: e.target.y() };
dispatch(setStageCoordinates(newCoordinates));
},
[dispatch, isStaging]
);
const handleDragEnd = useCallback(() => {
if (!(($tool.get() === 'move' || isStaging) && !$isMovingBoundingBox.get())) {
return;
}
$isMovingStage.set(false);
}, [isStaging]);
return {
handleDragStart,
handleDragMove,
handleDragEnd,
};
};
export default useCanvasDrag;

View File

@ -1,49 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import type { GenerationMode } from 'features/canvas/store/canvasTypes';
import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { useEffect, useState } from 'react';
import { useDebounce } from 'react-use';
export const useCanvasGenerationMode = () => {
const layerState = useAppSelector((s) => s.canvas.layerState);
const boundingBoxCoordinates = useAppSelector((s) => s.canvas.boundingBoxCoordinates);
const boundingBoxDimensions = useAppSelector((s) => s.canvas.boundingBoxDimensions);
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const shouldPreserveMaskedArea = useAppSelector((s) => s.canvas.shouldPreserveMaskedArea);
const [generationMode, setGenerationMode] = useState<GenerationMode | undefined>();
useEffect(() => {
setGenerationMode(undefined);
}, [layerState, boundingBoxCoordinates, boundingBoxDimensions, isMaskEnabled, shouldPreserveMaskedArea]);
useDebounce(
async () => {
// Build canvas blobs
const canvasBlobsAndImageData = await getCanvasData(
layerState,
boundingBoxCoordinates,
boundingBoxDimensions,
isMaskEnabled,
shouldPreserveMaskedArea
);
if (!canvasBlobsAndImageData) {
return;
}
const { baseImageData, maskImageData } = canvasBlobsAndImageData;
// Determine the generation mode
const generationMode = getCanvasGenerationMode(baseImageData, maskImageData);
setGenerationMode(generationMode);
},
1000,
[layerState, boundingBoxCoordinates, boundingBoxDimensions, isMaskEnabled, shouldPreserveMaskedArea]
);
return generationMode;
};

View File

@ -1,117 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$canvasStage,
$tool,
$toolStash,
resetCanvasInteractionState,
resetToolInteractionState,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { clearMask, setIsMaskEnabled, setShouldSnapToGrid } from 'features/canvas/store/canvasSlice';
import { isInteractiveTarget } from 'features/canvas/util/isInteractiveTarget';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const useInpaintingCanvasHotkeys = () => {
const dispatch = useAppDispatch();
const activeTabName = useAppSelector(activeTabNameSelector);
const isStaging = useAppSelector(isStagingSelector);
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const shouldSnapToGrid = useAppSelector((s) => s.canvas.shouldSnapToGrid);
// Beta Keys
const handleClearMask = useCallback(() => dispatch(clearMask()), [dispatch]);
useHotkeys(
['shift+c'],
() => {
handleClearMask();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[]
);
const handleToggleEnableMask = () => dispatch(setIsMaskEnabled(!isMaskEnabled));
useHotkeys(
['h'],
() => {
handleToggleEnableMask();
},
{
enabled: () => !isStaging,
preventDefault: true,
},
[isMaskEnabled]
);
useHotkeys(
['n'],
() => {
dispatch(setShouldSnapToGrid(!shouldSnapToGrid));
},
{
enabled: true,
preventDefault: true,
},
[shouldSnapToGrid]
);
//
useHotkeys(
'esc',
() => {
resetCanvasInteractionState();
},
{
enabled: () => true,
preventDefault: true,
}
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'canvas') {
return;
}
if ($toolStash.get() || $tool.get() === 'move') {
return;
}
$canvasStage.get()?.container().focus();
$toolStash.set($tool.get());
$tool.set('move');
resetToolInteractionState();
},
[activeTabName]
);
const onKeyUp = useCallback(
(e: KeyboardEvent) => {
if (e.repeat || e.key !== ' ' || isInteractiveTarget(e.target) || activeTabName !== 'canvas') {
return;
}
if (!$toolStash.get() || $tool.get() !== 'move') {
return;
}
$canvasStage.get()?.container().focus();
$tool.set($toolStash.get() ?? 'move');
$toolStash.set(null);
},
[activeTabName]
);
useEffect(() => {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [onKeyDown, onKeyUp]);
};
export default useInpaintingCanvasHotkeys;

View File

@ -1,59 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isDrawing, $isMovingStage, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addLine } from 'features/canvas/store/canvasSlice';
import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { MutableRefObject } from 'react';
import { useCallback } from 'react';
import useColorPicker from './useColorUnderCursor';
const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const isStaging = useAppSelector(isStagingSelector);
const { commitColorUnderCursor } = useColorPicker();
return useCallback(
(e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (!stageRef.current) {
return;
}
stageRef.current.container().focus();
const tool = $tool.get();
if (tool === 'move' || isStaging) {
$isMovingStage.set(true);
return;
}
if (tool === 'colorPicker') {
commitColorUnderCursor();
return;
}
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) {
return;
}
e.evt.preventDefault();
$isDrawing.set(true);
// Add a new line starting from the current cursor position.
dispatch(
addLine({
points: [scaledCursorPosition.x, scaledCursorPosition.y],
tool,
})
);
},
[stageRef, isStaging, dispatch, commitColorUnderCursor]
);
};
export default useCanvasMouseDown;

View File

@ -1,52 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $cursorPosition, $isDrawing, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addPointToCurrentLine } from 'features/canvas/store/canvasSlice';
import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition';
import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types';
import type { MutableRefObject } from 'react';
import { useCallback } from 'react';
import useColorPicker from './useColorUnderCursor';
const useCanvasMouseMove = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>,
lastCursorPositionRef: MutableRefObject<Vector2d>
) => {
const dispatch = useAppDispatch();
const isStaging = useAppSelector(isStagingSelector);
const { updateColorUnderCursor } = useColorPicker();
return useCallback(() => {
if (!stageRef.current) {
return;
}
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) {
return;
}
$cursorPosition.set(scaledCursorPosition);
lastCursorPositionRef.current = scaledCursorPosition;
const tool = $tool.get();
if (tool === 'colorPicker') {
updateColorUnderCursor();
return;
}
if (!$isDrawing.get() || tool === 'move' || isStaging) {
return;
}
didMouseMoveRef.current = true;
dispatch(addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y]));
}, [didMouseMoveRef, dispatch, isStaging, lastCursorPositionRef, stageRef, updateColorUnderCursor]);
};
export default useCanvasMouseMove;

View File

@ -1,12 +0,0 @@
import { setCanvasInteractionStateMouseOut } from 'features/canvas/store/canvasNanostore';
import { useCallback } from 'react';
const useCanvasMouseOut = () => {
const onMouseOut = useCallback(() => {
setCanvasInteractionStateMouseOut();
}, []);
return onMouseOut;
};
export default useCanvasMouseOut;

View File

@ -1,46 +0,0 @@
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isDrawing, $isMovingStage, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addPointToCurrentLine } from 'features/canvas/store/canvasSlice';
import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition';
import type Konva from 'konva';
import type { MutableRefObject } from 'react';
import { useCallback } from 'react';
const useCanvasMouseUp = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>
) => {
const dispatch = useAppDispatch();
const isDrawing = useStore($isDrawing);
const isStaging = useAppSelector(isStagingSelector);
return useCallback(() => {
if ($tool.get() === 'move' || isStaging) {
$isMovingStage.set(false);
return;
}
if (!didMouseMoveRef.current && isDrawing && stageRef.current) {
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) {
return;
}
/**
* Extend the current line.
* In this case, the mouse didn't move, so we append the same point to
* the line's existing points. This allows the line to render as a circle
* centered on that point.
*/
dispatch(addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y]));
} else {
didMouseMoveRef.current = false;
}
$isDrawing.set(false);
}, [didMouseMoveRef, dispatch, isDrawing, isStaging, stageRef]);
};
export default useCanvasMouseUp;

View File

@ -1,85 +0,0 @@
import { $ctrl, $meta } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isMoveStageKeyHeld } from 'features/canvas/store/canvasNanostore';
import { setBrushSize, setStageCoordinates, setStageScale } from 'features/canvas/store/canvasSlice';
import { CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/canvas/util/constants';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { clamp } from 'lodash-es';
import type { MutableRefObject } from 'react';
import { useCallback } from 'react';
export const calculateNewBrushSize = (brushSize: number, delta: number) => {
// This equation was derived by fitting a curve to the desired brush sizes and deltas
// see https://github.com/invoke-ai/InvokeAI/pull/5542#issuecomment-1915847565
const targetDelta = Math.sign(delta) * 0.7363 * Math.pow(1.0394, brushSize);
// This needs to be clamped to prevent the delta from getting too large
const finalDelta = clamp(targetDelta, -20, 20);
// The new brush size is also clamped to prevent it from getting too large or small
const newBrushSize = clamp(brushSize + finalDelta, 1, 500);
return newBrushSize;
};
const useCanvasWheel = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const isMoveStageKeyHeld = useStore($isMoveStageKeyHeld);
const brushSize = useAppSelector((s) => s.canvas.brushSize);
const shouldInvertBrushSizeScrollDirection = useAppSelector((s) => s.canvas.shouldInvertBrushSizeScrollDirection);
return useCallback(
(e: KonvaEventObject<WheelEvent>) => {
// stop default scrolling
if (!stageRef.current || isMoveStageKeyHeld) {
return;
}
e.evt.preventDefault();
// checking for ctrl key is pressed or not,
// so that brush size can be controlled using ctrl + scroll up/down
// Invert the delta if the property is set to true
let delta = e.evt.deltaY;
if (shouldInvertBrushSizeScrollDirection) {
delta = -delta;
}
if ($ctrl.get() || $meta.get()) {
dispatch(setBrushSize(calculateNewBrushSize(brushSize, delta)));
} else {
const cursorPos = stageRef.current.getPointerPosition();
let delta = e.evt.deltaY;
if (!cursorPos) {
return;
}
const mousePointTo = {
x: (cursorPos.x - stageRef.current.x()) / stageScale,
y: (cursorPos.y - stageRef.current.y()) / stageScale,
};
// when we zoom on trackpad, e.evt.ctrlKey is true
// in that case lets revert direction
if (e.evt.ctrlKey) {
delta = -delta;
}
const newScale = clamp(stageScale * CANVAS_SCALE_BY ** delta, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE);
const newCoordinates = {
x: cursorPos.x - mousePointTo.x * newScale,
y: cursorPos.y - mousePointTo.y * newScale,
};
dispatch(setStageScale(newScale));
dispatch(setStageCoordinates(newCoordinates));
}
},
[stageRef, isMoveStageKeyHeld, brushSize, dispatch, stageScale, shouldInvertBrushSizeScrollDirection]
);
};
export default useCanvasWheel;

View File

@ -1,44 +0,0 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { $canvasBaseLayer, $canvasStage, $tool } from 'features/canvas/store/canvasNanostore';
import { commitColorPickerColor, setColorPickerColor } from 'features/canvas/store/canvasSlice';
import Konva from 'konva';
import { useCallback } from 'react';
const useColorPicker = () => {
const dispatch = useAppDispatch();
const updateColorUnderCursor = useCallback(() => {
const stage = $canvasStage.get();
const canvasBaseLayer = $canvasBaseLayer.get();
if (!stage || !canvasBaseLayer) {
return;
}
const position = stage.getPointerPosition();
if (!position) {
return;
}
const pixelRatio = Konva.pixelRatio;
const [r, g, b, a] = canvasBaseLayer
.getContext()
.getImageData(position.x * pixelRatio, position.y * pixelRatio, 1, 1).data;
if (r === undefined || g === undefined || b === undefined || a === undefined) {
return;
}
dispatch(setColorPickerColor({ r, g, b, a }));
}, [dispatch]);
const commitColorUnderCursor = useCallback(() => {
dispatch(commitColorPickerColor());
$tool.set('brush');
}, [dispatch]);
return { updateColorUnderCursor, commitColorUnderCursor };
};
export default useColorPicker;

View File

@ -1,18 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import type { ImageDTO } from 'services/api/types';
export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery');
export const canvasMaskSavedToGallery = createAction('canvas/canvasMaskSavedToGallery');
export const canvasCopiedToClipboard = createAction('canvas/canvasCopiedToClipboard');
export const canvasDownloadedAsImage = createAction('canvas/canvasDownloadedAsImage');
export const canvasMerged = createAction('canvas/canvasMerged');
export const stagingAreaImageSaved = createAction<{ imageDTO: ImageDTO }>('canvas/stagingAreaImageSaved');
export const canvasMaskToControlAdapter = createAction<{ id: string }>('canvas/canvasMaskToControlAdapter');
export const canvasImageToControlAdapter = createAction<{ id: string }>('canvas/canvasImageToControlAdapter');

View File

@ -1,41 +0,0 @@
import type { CanvasTool } from 'features/canvas/store/canvasTypes';
import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types';
import { atom, computed } from 'nanostores';
export const $cursorPosition = atom<Vector2d | null>(null);
export const $tool = atom<CanvasTool>('move');
export const $toolStash = atom<CanvasTool | null>(null);
export const $isDrawing = atom<boolean>(false);
export const $isMouseOverBoundingBox = atom<boolean>(false);
const $isMoveBoundingBoxKeyHeld = atom<boolean>(false);
export const $isMoveStageKeyHeld = atom<boolean>(false);
export const $isMovingBoundingBox = atom<boolean>(false);
export const $isMovingStage = atom<boolean>(false);
export const $isTransformingBoundingBox = atom<boolean>(false);
export const $isMouseOverBoundingBoxOutline = atom<boolean>(false);
export const $isModifyingBoundingBox = computed(
[$isTransformingBoundingBox, $isMovingBoundingBox],
(isTransformingBoundingBox, isMovingBoundingBox) => isTransformingBoundingBox || isMovingBoundingBox
);
export const resetCanvasInteractionState = () => {
$cursorPosition.set(null);
$isDrawing.set(false);
$isMoveBoundingBoxKeyHeld.set(false);
$isMoveStageKeyHeld.set(false);
$isMovingBoundingBox.set(false);
$isMovingStage.set(false);
};
export const resetToolInteractionState = () => {
$isTransformingBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMovingStage.set(false);
};
export const setCanvasInteractionStateMouseOut = () => {
$cursorPosition.set(null);
};
export const $canvasBaseLayer = atom<Konva.Layer | null>(null);
export const $canvasStage = atom<Konva.Stage | null>(null);

View File

@ -1,8 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { selectCanvasSlice } from './canvasSlice';
export const isStagingSelector = createSelector(
selectCanvasSlice,
(canvas) => canvas.batchIds.length > 0 || canvas.layerState.stagingArea.images.length > 0
);

View File

@ -1,728 +0,0 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple';
import { modelChanged } from 'features/canvas/store/canvasSlice';
import calculateCoordinates from 'features/canvas/util/calculateCoordinates';
import calculateScale from 'features/canvas/util/calculateScale';
import { STAGE_PADDING_PERCENTAGE } from 'features/canvas/util/constants';
import floorCoordinates from 'features/canvas/util/floorCoordinates';
import getScaledBoundingBoxDimensions from 'features/canvas/util/getScaledBoundingBoxDimensions';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { initialAspectRatioState } from 'features/parameters/components/ImageSize/constants';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import type { PayloadActionWithOptimalDimension } from 'features/parameters/store/types';
import { getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect, Vector2d } from 'konva/lib/types';
import { clamp } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import type {
BoundingBoxScaleMethod,
CanvasBaseLine,
CanvasImage,
CanvasLayer,
CanvasLayerState,
CanvasMaskLine,
CanvasState,
CanvasTool,
Dimensions,
} from './canvasTypes';
import { isCanvasAnyLine, isCanvasMaskLine } from './canvasTypes';
import { CANVAS_GRID_SIZE_FINE } from './constants';
/**
* The maximum history length to keep in the past/future layer states.
*/
const MAX_HISTORY = 100;
const initialLayerState: CanvasLayerState = {
objects: [],
stagingArea: {
images: [],
selectedImageIndex: -1,
},
};
const initialCanvasState: CanvasState = {
_version: 1,
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxScaleMethod: 'auto',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
colorPickerColor: { r: 90, g: 90, b: 255, a: 1 },
futureLayerStates: [],
isMaskEnabled: true,
layer: 'base',
layerState: initialLayerState,
maskColor: { r: 255, g: 90, b: 90, a: 1 },
pastLayerStates: [],
scaledBoundingBoxDimensions: { width: 512, height: 512 },
shouldAntialias: true,
shouldAutoSave: false,
shouldCropToBoundingBoxOnSave: false,
shouldDarkenOutsideBoundingBox: false,
shouldFitImageSize: true,
shouldInvertBrushSizeScrollDirection: false,
shouldLockBoundingBox: false,
shouldPreserveMaskedArea: false,
shouldRestrictStrokesToBox: true,
shouldShowBoundingBox: true,
shouldShowCanvasDebugInfo: false,
shouldShowGrid: true,
shouldShowIntermediates: true,
shouldShowStagingImage: true,
shouldShowStagingOutline: true,
shouldSnapToGrid: true,
stageCoordinates: { x: 0, y: 0 },
stageDimensions: { width: 0, height: 0 },
stageScale: 1,
batchIds: [],
aspectRatio: {
id: '1:1',
value: 1,
isLocked: false,
},
};
const setBoundingBoxDimensionsReducer = (
state: CanvasState,
payload: Partial<Dimensions>,
optimalDimension: number
) => {
const boundingBoxDimensions = payload;
const newDimensions = {
...state.boundingBoxDimensions,
...boundingBoxDimensions,
};
state.boundingBoxDimensions = newDimensions;
if (state.boundingBoxScaleMethod === 'auto') {
const scaledDimensions = getScaledBoundingBoxDimensions(newDimensions, optimalDimension);
state.scaledBoundingBoxDimensions = scaledDimensions;
}
};
export const canvasSlice = createSlice({
name: 'canvas',
initialState: initialCanvasState,
reducers: {
setLayer: (state, action: PayloadAction<CanvasLayer>) => {
state.layer = action.payload;
},
setMaskColor: (state, action: PayloadAction<RgbaColor>) => {
state.maskColor = action.payload;
},
setBrushColor: (state, action: PayloadAction<RgbaColor>) => {
state.brushColor = action.payload;
},
setBrushSize: (state, action: PayloadAction<number>) => {
state.brushSize = action.payload;
},
clearMask: (state) => {
pushToPrevLayerStates(state);
state.layerState.objects = state.layerState.objects.filter((obj) => !isCanvasMaskLine(obj));
state.futureLayerStates = [];
state.shouldPreserveMaskedArea = false;
},
toggleShouldInvertMask: (state) => {
state.shouldPreserveMaskedArea = !state.shouldPreserveMaskedArea;
},
toggleShouldShowMask: (state) => {
state.isMaskEnabled = !state.isMaskEnabled;
},
setShouldPreserveMaskedArea: (state, action: PayloadAction<boolean>) => {
state.shouldPreserveMaskedArea = action.payload;
},
setIsMaskEnabled: (state, action: PayloadAction<boolean>) => {
state.isMaskEnabled = action.payload;
state.layer = action.payload ? 'mask' : 'base';
},
setInitialCanvasImage: {
reducer: (state, action: PayloadActionWithOptimalDimension<ImageDTO>) => {
const { width, height, image_name } = action.payload;
const { optimalDimension } = action.meta;
const { stageDimensions, shouldFitImageSize } = state;
const newBoundingBoxDimensions = shouldFitImageSize
? {
width: roundDownToMultiple(width, CANVAS_GRID_SIZE_FINE),
height: roundDownToMultiple(height, CANVAS_GRID_SIZE_FINE),
}
: {
width: roundDownToMultiple(clamp(width, CANVAS_GRID_SIZE_FINE, optimalDimension), CANVAS_GRID_SIZE_FINE),
height: roundDownToMultiple(
clamp(height, CANVAS_GRID_SIZE_FINE, optimalDimension),
CANVAS_GRID_SIZE_FINE
),
};
const newBoundingBoxCoordinates = {
x: roundToMultiple(width / 2 - newBoundingBoxDimensions.width / 2, CANVAS_GRID_SIZE_FINE),
y: roundToMultiple(height / 2 - newBoundingBoxDimensions.height / 2, CANVAS_GRID_SIZE_FINE),
};
if (state.boundingBoxScaleMethod === 'auto') {
const scaledDimensions = getScaledBoundingBoxDimensions(newBoundingBoxDimensions, optimalDimension);
state.scaledBoundingBoxDimensions = scaledDimensions;
}
state.boundingBoxDimensions = newBoundingBoxDimensions;
state.boundingBoxCoordinates = newBoundingBoxCoordinates;
pushToPrevLayerStates(state);
state.layerState = {
...deepClone(initialLayerState),
objects: [
{
kind: 'image',
layer: 'base',
x: 0,
y: 0,
width,
height,
imageName: image_name,
},
],
};
state.futureLayerStates = [];
const newScale = calculateScale(
stageDimensions.width,
stageDimensions.height,
width,
height,
STAGE_PADDING_PERCENTAGE
);
const newCoordinates = calculateCoordinates(
stageDimensions.width,
stageDimensions.height,
0,
0,
width,
height,
newScale
);
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
},
prepare: (payload: ImageDTO, optimalDimension: number) => ({
payload,
meta: {
optimalDimension,
},
}),
},
setBoundingBoxCoordinates: (state, action: PayloadAction<Vector2d>) => {
state.boundingBoxCoordinates = floorCoordinates(action.payload);
},
setStageCoordinates: (state, action: PayloadAction<Vector2d>) => {
state.stageCoordinates = action.payload;
},
setStageScale: (state, action: PayloadAction<number>) => {
state.stageScale = action.payload;
},
setShouldDarkenOutsideBoundingBox: (state, action: PayloadAction<boolean>) => {
state.shouldDarkenOutsideBoundingBox = action.payload;
},
setShouldInvertBrushSizeScrollDirection: (state, action: PayloadAction<boolean>) => {
state.shouldInvertBrushSizeScrollDirection = action.payload;
},
clearCanvasHistory: (state) => {
state.pastLayerStates = [];
state.futureLayerStates = [];
},
setShouldLockBoundingBox: (state, action: PayloadAction<boolean>) => {
state.shouldLockBoundingBox = action.payload;
},
setShouldShowBoundingBox: (state, action: PayloadAction<boolean>) => {
state.shouldShowBoundingBox = action.payload;
},
canvasBatchIdAdded: (state, action: PayloadAction<string>) => {
state.batchIds.push(action.payload);
},
canvasBatchIdsReset: (state) => {
state.batchIds = [];
},
stagingAreaInitialized: (
state,
action: PayloadAction<{
boundingBox: IRect;
}>
) => {
const { boundingBox } = action.payload;
state.layerState.stagingArea = {
boundingBox,
images: [],
selectedImageIndex: -1,
};
},
addImageToStagingArea: (state, action: PayloadAction<ImageDTO>) => {
const image = action.payload;
if (!image || !state.layerState.stagingArea.boundingBox) {
return;
}
pushToPrevLayerStates(state);
state.layerState.stagingArea.images.push({
kind: 'image',
layer: 'base',
...state.layerState.stagingArea.boundingBox,
imageName: image.image_name,
});
state.layerState.stagingArea.selectedImageIndex = state.layerState.stagingArea.images.length - 1;
state.futureLayerStates = [];
},
discardStagedImages: (state) => {
pushToPrevLayerStates(state);
resetStagingArea(state);
state.futureLayerStates = [];
},
discardStagedImage: (state) => {
const { images, selectedImageIndex } = state.layerState.stagingArea;
pushToPrevLayerStates(state);
images.splice(selectedImageIndex, 1);
state.layerState.stagingArea.selectedImageIndex = Math.max(0, images.length - 1);
state.futureLayerStates = [];
},
addFillRect: (state) => {
const { boundingBoxCoordinates, boundingBoxDimensions, brushColor } = state;
pushToPrevLayerStates(state);
state.layerState.objects.push({
kind: 'fillRect',
layer: 'base',
...boundingBoxCoordinates,
...boundingBoxDimensions,
color: brushColor,
});
state.futureLayerStates = [];
},
addEraseRect: (state) => {
const { boundingBoxCoordinates, boundingBoxDimensions } = state;
pushToPrevLayerStates(state);
state.layerState.objects.push({
kind: 'eraseRect',
layer: 'base',
...boundingBoxCoordinates,
...boundingBoxDimensions,
});
state.futureLayerStates = [];
},
addLine: (state, action: PayloadAction<{ points: number[]; tool: CanvasTool }>) => {
const { layer, brushColor, brushSize, shouldRestrictStrokesToBox } = state;
const { points, tool } = action.payload;
if (tool === 'move' || tool === 'colorPicker') {
return;
}
const newStrokeWidth = brushSize / 2;
// set & then spread this to only conditionally add the "color" key
const newColor = layer === 'base' && tool === 'brush' ? { color: brushColor } : {};
pushToPrevLayerStates(state);
const newLine: CanvasMaskLine | CanvasBaseLine = {
kind: 'line',
layer,
tool,
strokeWidth: newStrokeWidth,
points,
...newColor,
};
if (shouldRestrictStrokesToBox) {
newLine.clip = {
...state.boundingBoxCoordinates,
...state.boundingBoxDimensions,
};
}
state.layerState.objects.push(newLine);
state.futureLayerStates = [];
},
addPointToCurrentLine: (state, action: PayloadAction<number[]>) => {
const lastLine = state.layerState.objects.findLast(isCanvasAnyLine);
if (!lastLine) {
return;
}
lastLine.points.push(...action.payload);
},
undo: (state) => {
const targetState = state.pastLayerStates.pop();
if (!targetState) {
return;
}
pushToFutureLayerStates(state);
state.layerState = targetState;
},
redo: (state) => {
const targetState = state.futureLayerStates.shift();
if (!targetState) {
return;
}
pushToPrevLayerStates(state);
state.layerState = targetState;
},
setShouldShowGrid: (state, action: PayloadAction<boolean>) => {
state.shouldShowGrid = action.payload;
},
setShouldSnapToGrid: (state, action: PayloadAction<boolean>) => {
state.shouldSnapToGrid = action.payload;
},
setShouldAutoSave: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSave = action.payload;
},
setShouldShowIntermediates: (state, action: PayloadAction<boolean>) => {
state.shouldShowIntermediates = action.payload;
},
resetCanvas: (state) => {
pushToPrevLayerStates(state);
state.layerState = deepClone(initialLayerState);
state.futureLayerStates = [];
state.boundingBoxCoordinates = {
...initialCanvasState.boundingBoxCoordinates,
};
state.boundingBoxDimensions = {
...initialCanvasState.boundingBoxDimensions,
};
state.stageScale = calculateScale(
state.stageDimensions.width,
state.stageDimensions.height,
state.boundingBoxDimensions.width,
state.boundingBoxDimensions.height,
STAGE_PADDING_PERCENTAGE
);
state.stageCoordinates = calculateCoordinates(
state.stageDimensions.width,
state.stageDimensions.height,
0,
0,
state.boundingBoxDimensions.width,
state.boundingBoxDimensions.height,
1
);
},
canvasResized: (state, action: PayloadAction<{ width: number; height: number }>) => {
state.stageDimensions = {
width: Math.floor(action.payload.width),
height: Math.floor(action.payload.height),
};
},
resetCanvasView: (
state,
action: PayloadAction<{
contentRect: IRect;
shouldScaleTo1?: boolean;
}>
) => {
const { contentRect, shouldScaleTo1 } = action.payload;
const {
stageDimensions: { width: stageWidth, height: stageHeight },
} = state;
const newScale = shouldScaleTo1
? 1
: calculateScale(
stageWidth,
stageHeight,
contentRect.width || state.boundingBoxDimensions.width,
contentRect.height || state.boundingBoxDimensions.height,
STAGE_PADDING_PERCENTAGE
);
const newCoordinates = calculateCoordinates(
stageWidth,
stageHeight,
contentRect.x || state.boundingBoxCoordinates.x,
contentRect.y || state.boundingBoxCoordinates.y,
contentRect.width || state.boundingBoxDimensions.width,
contentRect.height || state.boundingBoxDimensions.height,
newScale
);
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
},
nextStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const nextIndex = state.layerState.stagingArea.selectedImageIndex + 1;
const lastIndex = state.layerState.stagingArea.images.length - 1;
state.layerState.stagingArea.selectedImageIndex = nextIndex > lastIndex ? 0 : nextIndex;
},
prevStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const prevIndex = state.layerState.stagingArea.selectedImageIndex - 1;
const lastIndex = state.layerState.stagingArea.images.length - 1;
state.layerState.stagingArea.selectedImageIndex = prevIndex < 0 ? lastIndex : prevIndex;
},
commitStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const { images, selectedImageIndex } = state.layerState.stagingArea;
pushToPrevLayerStates(state);
const imageToCommit = images[selectedImageIndex];
if (imageToCommit) {
state.layerState.objects.push({
...imageToCommit,
});
}
resetStagingArea(state);
state.futureLayerStates = [];
},
setBoundingBoxScaleMethod: {
reducer: (state, action: PayloadActionWithOptimalDimension<BoundingBoxScaleMethod>) => {
const boundingBoxScaleMethod = action.payload;
const { optimalDimension } = action.meta;
state.boundingBoxScaleMethod = boundingBoxScaleMethod;
if (boundingBoxScaleMethod === 'auto') {
const scaledDimensions = getScaledBoundingBoxDimensions(state.boundingBoxDimensions, optimalDimension);
state.scaledBoundingBoxDimensions = scaledDimensions;
}
},
prepare: (payload: BoundingBoxScaleMethod, optimalDimension: number) => ({
payload,
meta: {
optimalDimension,
},
}),
},
setScaledBoundingBoxDimensions: (state, action: PayloadAction<Partial<Dimensions>>) => {
state.scaledBoundingBoxDimensions = {
...state.scaledBoundingBoxDimensions,
...action.payload,
};
},
setBoundingBoxDimensions: {
reducer: (state, action: PayloadActionWithOptimalDimension<Partial<Dimensions>>) => {
setBoundingBoxDimensionsReducer(state, action.payload, action.meta.optimalDimension);
},
prepare: (payload: Partial<Dimensions>, optimalDimension: number) => ({
payload,
meta: {
optimalDimension,
},
}),
},
setShouldShowStagingImage: (state, action: PayloadAction<boolean>) => {
state.shouldShowStagingImage = action.payload;
},
setShouldShowStagingOutline: (state, action: PayloadAction<boolean>) => {
state.shouldShowStagingOutline = action.payload;
},
setShouldShowCanvasDebugInfo: (state, action: PayloadAction<boolean>) => {
state.shouldShowCanvasDebugInfo = action.payload;
},
setShouldRestrictStrokesToBox: (state, action: PayloadAction<boolean>) => {
state.shouldRestrictStrokesToBox = action.payload;
},
setShouldAntialias: (state, action: PayloadAction<boolean>) => {
state.shouldAntialias = action.payload;
},
setShouldFitImageSize: (state, action: PayloadAction<boolean>) => {
state.shouldFitImageSize = action.payload;
},
setShouldCropToBoundingBoxOnSave: (state, action: PayloadAction<boolean>) => {
state.shouldCropToBoundingBoxOnSave = action.payload;
},
setColorPickerColor: (state, action: PayloadAction<RgbaColor>) => {
state.colorPickerColor = action.payload;
},
commitColorPickerColor: (state) => {
state.brushColor = {
...state.colorPickerColor,
a: state.brushColor.a,
};
},
setMergedCanvas: (state, action: PayloadAction<CanvasImage>) => {
pushToPrevLayerStates(state);
state.futureLayerStates = [];
state.layerState.objects = [action.payload];
},
aspectRatioChanged: (state, action: PayloadAction<AspectRatioState>) => {
state.aspectRatio = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(modelChanged, (state, action) => {
const newModel = action.payload;
if (!newModel || action.meta.previousModel?.base === newModel.base) {
// Model was cleared or the base didn't change
return;
}
const optimalDimension = getOptimalDimension(action.payload);
const { width, height } = state.boundingBoxDimensions;
if (getIsSizeOptimal(width, height, optimalDimension)) {
return;
}
const newSize = calculateNewSize(state.aspectRatio.value, optimalDimension * optimalDimension);
setBoundingBoxDimensionsReducer(state, newSize, optimalDimension);
});
builder.addCase(socketQueueItemStatusChanged, (state, action) => {
const batch_status = action.payload.data.batch_status;
if (!state.batchIds.includes(batch_status.batch_id)) {
return;
}
if (batch_status.in_progress === 0 && batch_status.pending === 0) {
state.batchIds = state.batchIds.filter((id) => id !== batch_status.batch_id);
}
const queueItemStatus = action.payload.data.status;
if (queueItemStatus === 'canceled' || queueItemStatus === 'failed') {
resetStagingAreaIfEmpty(state);
}
});
builder.addMatcher(queueApi.endpoints.clearQueue.matchFulfilled, (state) => {
state.batchIds = [];
resetStagingAreaIfEmpty(state);
});
builder.addMatcher(queueApi.endpoints.cancelByBatchIds.matchFulfilled, (state, action) => {
state.batchIds = state.batchIds.filter((id) => !action.meta.arg.originalArgs.batch_ids.includes(id));
resetStagingAreaIfEmpty(state);
});
},
});
export const {
addEraseRect,
addFillRect,
addImageToStagingArea,
addLine,
addPointToCurrentLine,
clearCanvasHistory,
clearMask,
commitColorPickerColor,
commitStagingAreaImage,
discardStagedImages,
discardStagedImage,
nextStagingAreaImage,
prevStagingAreaImage,
redo,
resetCanvas,
resetCanvasView,
setBoundingBoxCoordinates,
setBoundingBoxDimensions,
setBoundingBoxScaleMethod,
setBrushColor,
setBrushSize,
setColorPickerColor,
setInitialCanvasImage,
setIsMaskEnabled,
setLayer,
setMaskColor,
setMergedCanvas,
setShouldAutoSave,
setShouldCropToBoundingBoxOnSave,
setShouldDarkenOutsideBoundingBox,
setShouldInvertBrushSizeScrollDirection,
setShouldPreserveMaskedArea,
setShouldShowBoundingBox,
setShouldShowCanvasDebugInfo,
setShouldShowGrid,
setShouldShowIntermediates,
setShouldShowStagingImage,
setShouldShowStagingOutline,
setShouldSnapToGrid,
setStageCoordinates,
setStageScale,
undo,
setScaledBoundingBoxDimensions,
setShouldRestrictStrokesToBox,
stagingAreaInitialized,
setShouldAntialias,
setShouldFitImageSize,
canvasResized,
canvasBatchIdAdded,
canvasBatchIdsReset,
aspectRatioChanged,
} = canvasSlice.actions;
export const selectCanvasSlice = (state: RootState) => state.canvas;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateCanvasState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
state.aspectRatio = initialAspectRatioState;
}
return state;
};
export const canvasPersistConfig: PersistConfig<CanvasState> = {
name: canvasSlice.name,
initialState: initialCanvasState,
migrate: migrateCanvasState,
persistDenylist: ['shouldShowStagingImage', 'shouldShowStagingOutline'],
};
const pushToPrevLayerStates = (state: CanvasState) => {
state.pastLayerStates.push(deepClone(state.layerState));
if (state.pastLayerStates.length > MAX_HISTORY) {
state.pastLayerStates = state.pastLayerStates.slice(-MAX_HISTORY);
}
};
const pushToFutureLayerStates = (state: CanvasState) => {
state.futureLayerStates.unshift(deepClone(state.layerState));
if (state.futureLayerStates.length > MAX_HISTORY) {
state.futureLayerStates = state.futureLayerStates.slice(0, MAX_HISTORY);
}
};
const resetStagingAreaIfEmpty = (state: CanvasState) => {
if (state.batchIds.length === 0 && state.layerState.stagingArea.images.length === 0) {
resetStagingArea(state);
}
};
const resetStagingArea = (state: CanvasState) => {
state.layerState.stagingArea = { ...initialCanvasState.layerState.stagingArea };
state.shouldShowStagingImage = initialCanvasState.shouldShowStagingImage;
state.shouldShowStagingOutline = initialCanvasState.shouldShowStagingOutline;
};

View File

@ -1,138 +0,0 @@
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import type { IRect, Vector2d } from 'konva/lib/types';
import type { RgbaColor } from 'react-colorful';
import { z } from 'zod';
export type CanvasLayer = 'base' | 'mask';
const zBoundingBoxScaleMethod = z.enum(['none', 'auto', 'manual']);
export type BoundingBoxScaleMethod = z.infer<typeof zBoundingBoxScaleMethod>;
export const isBoundingBoxScaleMethod = (v: unknown): v is BoundingBoxScaleMethod =>
zBoundingBoxScaleMethod.safeParse(v).success;
type CanvasDrawingTool = 'brush' | 'eraser';
export type CanvasTool = CanvasDrawingTool | 'move' | 'colorPicker';
export type Dimensions = {
width: number;
height: number;
};
export type CanvasImage = {
kind: 'image';
layer: 'base';
x: number;
y: number;
width: number;
height: number;
imageName: string;
};
export type CanvasMaskLine = {
layer: 'mask';
kind: 'line';
tool: CanvasDrawingTool;
strokeWidth: number;
points: number[];
clip?: IRect;
};
export type CanvasBaseLine = {
layer: 'base';
color?: RgbaColor;
kind: 'line';
tool: CanvasDrawingTool;
strokeWidth: number;
points: number[];
clip?: IRect;
};
type CanvasFillRect = {
kind: 'fillRect';
layer: 'base';
x: number;
y: number;
width: number;
height: number;
color: RgbaColor;
};
type CanvasEraseRect = {
kind: 'eraseRect';
layer: 'base';
x: number;
y: number;
width: number;
height: number;
};
type CanvasObject = CanvasImage | CanvasBaseLine | CanvasMaskLine | CanvasFillRect | CanvasEraseRect;
export type CanvasLayerState = {
objects: CanvasObject[];
stagingArea: {
images: CanvasImage[];
selectedImageIndex: number;
boundingBox?: IRect;
};
};
// type guards
export const isCanvasMaskLine = (obj: CanvasObject): obj is CanvasMaskLine =>
obj.kind === 'line' && obj.layer === 'mask';
export const isCanvasBaseLine = (obj: CanvasObject): obj is CanvasBaseLine =>
obj.kind === 'line' && obj.layer === 'base';
export const isCanvasBaseImage = (obj: CanvasObject): obj is CanvasImage =>
obj.kind === 'image' && obj.layer === 'base';
export const isCanvasFillRect = (obj: CanvasObject): obj is CanvasFillRect =>
obj.kind === 'fillRect' && obj.layer === 'base';
export const isCanvasEraseRect = (obj: CanvasObject): obj is CanvasEraseRect =>
obj.kind === 'eraseRect' && obj.layer === 'base';
export const isCanvasAnyLine = (obj: CanvasObject): obj is CanvasMaskLine | CanvasBaseLine => obj.kind === 'line';
export interface CanvasState {
_version: 1;
boundingBoxCoordinates: Vector2d;
boundingBoxDimensions: Dimensions;
boundingBoxScaleMethod: BoundingBoxScaleMethod;
brushColor: RgbaColor;
brushSize: number;
colorPickerColor: RgbaColor;
futureLayerStates: CanvasLayerState[];
isMaskEnabled: boolean;
layer: CanvasLayer;
layerState: CanvasLayerState;
maskColor: RgbaColor;
pastLayerStates: CanvasLayerState[];
scaledBoundingBoxDimensions: Dimensions;
shouldAntialias: boolean;
shouldAutoSave: boolean;
shouldCropToBoundingBoxOnSave: boolean;
shouldDarkenOutsideBoundingBox: boolean;
shouldFitImageSize: boolean;
shouldInvertBrushSizeScrollDirection: boolean;
shouldLockBoundingBox: boolean;
shouldPreserveMaskedArea: boolean;
shouldRestrictStrokesToBox: boolean;
shouldShowBoundingBox: boolean;
shouldShowCanvasDebugInfo: boolean;
shouldShowGrid: boolean;
shouldShowIntermediates: boolean;
shouldShowStagingImage: boolean;
shouldShowStagingOutline: boolean;
shouldSnapToGrid: boolean;
stageCoordinates: Vector2d;
stageDimensions: Dimensions;
stageScale: number;
generationMode?: GenerationMode;
batchIds: string[];
aspectRatio: AspectRatioState;
}
export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint';

View File

@ -1,3 +0,0 @@
export const CANVAS_GRID_SIZE_COARSE = 64;
export const CANVAS_GRID_SIZE_FINE = 8;
export const CANVAS_TAB_TESTID = 'unified-canvas-tab';

View File

@ -1,28 +0,0 @@
export const blobToDataURL = (blob: Blob): Promise<string> => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (_e) => resolve(reader.result as string);
reader.onerror = (_e) => reject(reader.error);
reader.onabort = (_e) => reject(new Error('Read aborted'));
reader.readAsDataURL(blob);
});
};
export function imageDataToDataURL(imageData: ImageData): string {
const { width, height } = imageData;
// Create a canvas to transfer the ImageData to
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw the ImageData onto the canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Unable to get canvas context');
}
ctx.putImageData(imageData, 0, 0);
// Convert the canvas to a data URL (base64)
return canvas.toDataURL();
}

View File

@ -1,17 +0,0 @@
import type { Vector2d } from 'konva/lib/types';
const calculateCoordinates = (
containerWidth: number,
containerHeight: number,
containerX: number,
containerY: number,
contentWidth: number,
contentHeight: number,
scale: number
): Vector2d => {
const x = Math.floor(containerWidth / 2 - (containerX + contentWidth / 2) * scale);
const y = Math.floor(containerHeight / 2 - (containerY + contentHeight / 2) * scale);
return { x, y };
};
export default calculateCoordinates;

View File

@ -1,14 +0,0 @@
const calculateScale = (
containerWidth: number,
containerHeight: number,
contentWidth: number,
contentHeight: number,
padding = 0.95
): number => {
const scaleX = (containerWidth * padding) / contentWidth;
const scaleY = (containerHeight * padding) / contentHeight;
const scaleFit = Math.min(1, Math.min(scaleX, scaleY));
return scaleFit ? scaleFit : 1;
};
export default calculateScale;

View File

@ -1,13 +0,0 @@
/**
* Gets a Blob from a canvas.
*/
export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject('Unable to create Blob');
});
});

View File

@ -1,11 +0,0 @@
import type { RgbaColor, RgbColor } from 'react-colorful';
export const rgbaColorToString = (color: RgbaColor): string => {
const { r, g, b, a } = color;
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
export const rgbColorToString = (color: RgbColor): string => {
const { r, g, b } = color;
return `rgba(${r}, ${g}, ${b})`;
};

View File

@ -1,14 +0,0 @@
// canvas wheel zoom exponential scale factor
export const CANVAS_SCALE_BY = 0.999;
// minimum (furthest-zoomed-out) scale
export const MIN_CANVAS_SCALE = 0.1;
// maximum (furthest-zoomed-in) scale
export const MAX_CANVAS_SCALE = 20;
// padding given to initial image/bounding box when stage view is reset
export const STAGE_PADDING_PERCENTAGE = 0.95;
export const COLOR_PICKER_SIZE = 30;
export const COLOR_PICKER_STROKE_RADIUS = 10;

View File

@ -1,61 +0,0 @@
import type { CanvasMaskLine } from 'features/canvas/store/canvasTypes';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
/**
* Creates a stage from array of mask objects.
* We cannot just convert the mask layer to a blob because it uses a texture with transparent areas.
* So instead we create a new stage with the mask layer and composite it onto a white background.
*/
const createMaskStage = async (
lines: CanvasMaskLine[],
boundingBox: IRect,
shouldInvertMask: boolean
): Promise<Konva.Stage> => {
// create an offscreen canvas and add the mask to it
const { width, height } = boundingBox;
const offscreenContainer = document.createElement('div');
const maskStage = new Konva.Stage({
container: offscreenContainer,
width: width,
height: height,
});
const baseLayer = new Konva.Layer();
const maskLayer = new Konva.Layer();
// composite the image onto the mask layer
baseLayer.add(
new Konva.Rect({
...boundingBox,
fill: shouldInvertMask ? 'black' : 'white',
})
);
lines.forEach((line) =>
maskLayer.add(
new Konva.Line({
points: line.points,
stroke: shouldInvertMask ? 'white' : 'black',
strokeWidth: line.strokeWidth * 2,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
globalCompositeOperation: line.tool === 'brush' ? 'source-over' : 'destination-out',
})
)
);
maskStage.add(baseLayer);
maskStage.add(maskLayer);
// you'd think we can't do this until we finish with the maskStage, but we can
offscreenContainer.remove();
return maskStage;
};
export default createMaskStage;

View File

@ -1,25 +0,0 @@
/**
* Gets an ImageData object from an image dataURL by drawing it to a canvas.
*/
export const dataURLToImageData = async (dataURL: string, width: number, height: number): Promise<ImageData> =>
new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = new Image();
if (!ctx) {
canvas.remove();
reject('Unable to get context');
return;
}
image.onload = function () {
ctx.drawImage(image, 0, 0);
canvas.remove();
resolve(ctx.getImageData(0, 0, width, height));
};
image.src = dataURL;
});

View File

@ -1,11 +0,0 @@
/** Download a blob as a file */
export const downloadBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
a.remove();
};

View File

@ -1,10 +0,0 @@
import type { Vector2d } from 'konva/lib/types';
const floorCoordinates = (coord: Vector2d): Vector2d => {
return {
x: Math.floor(coord.x),
y: Math.floor(coord.y),
};
};
export default floorCoordinates;

View File

@ -1,35 +0,0 @@
import type { RootState } from 'app/store/store';
import { $canvasBaseLayer } from 'features/canvas/store/canvasNanostore';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
* Get the canvas base layer blob, with or without bounding box according to `shouldCropToBoundingBoxOnSave`
*/
export const getBaseLayerBlob = async (state: RootState, alwaysUseBoundingBox: boolean = false) => {
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
throw new Error('Problem getting base layer blob');
}
const { shouldCropToBoundingBoxOnSave, boundingBoxCoordinates, boundingBoxDimensions } = state.canvas;
const clonedBaseLayer = canvasBaseLayer.clone();
clonedBaseLayer.scale({ x: 1, y: 1 });
const absPos = clonedBaseLayer.getAbsolutePosition();
const boundingBox =
shouldCropToBoundingBoxOnSave || alwaysUseBoundingBox
? {
x: boundingBoxCoordinates.x + absPos.x,
y: boundingBoxCoordinates.y + absPos.y,
width: boundingBoxDimensions.width,
height: boundingBoxDimensions.height,
}
: clonedBaseLayer.getClientRect();
return konvaNodeToBlob(clonedBaseLayer, boundingBox);
};

View File

@ -1,71 +0,0 @@
import { logger } from 'app/logging/logger';
import { $canvasBaseLayer, $canvasStage } from 'features/canvas/store/canvasNanostore';
import type { CanvasLayerState, Dimensions } from 'features/canvas/store/canvasTypes';
import { isCanvasMaskLine } from 'features/canvas/store/canvasTypes';
import { konvaNodeToImageData } from 'features/canvas/util/konvaNodeToImageData';
import type { Vector2d } from 'konva/lib/types';
import createMaskStage from './createMaskStage';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
* Gets Blob and ImageData objects for the base and mask layers
*/
export const getCanvasData = async (
layerState: CanvasLayerState,
boundingBoxCoordinates: Vector2d,
boundingBoxDimensions: Dimensions,
isMaskEnabled: boolean,
shouldPreserveMaskedArea: boolean
) => {
const log = logger('canvas');
const canvasBaseLayer = $canvasBaseLayer.get();
const canvasStage = $canvasStage.get();
if (!canvasBaseLayer || !canvasStage) {
log.error('Unable to find canvas / stage');
return;
}
const boundingBox = {
...boundingBoxCoordinates,
...boundingBoxDimensions,
};
// Clone the base layer so we don't affect the visible base layer
const clonedBaseLayer = canvasBaseLayer.clone();
// Scale it to 100% so we get full resolution
clonedBaseLayer.scale({ x: 1, y: 1 });
// absolute position is needed to get the bounding box coords relative to the base layer
const absPos = clonedBaseLayer.getAbsolutePosition();
const offsetBoundingBox = {
x: boundingBox.x + absPos.x,
y: boundingBox.y + absPos.y,
width: boundingBox.width,
height: boundingBox.height,
};
// For the base layer, use the offset boundingBox
const baseBlob = await konvaNodeToBlob(clonedBaseLayer, offsetBoundingBox);
const baseImageData = await konvaNodeToImageData(clonedBaseLayer, offsetBoundingBox);
// For the mask layer, use the normal boundingBox
const maskStage = await createMaskStage(
isMaskEnabled ? layerState.objects.filter(isCanvasMaskLine) : [], // only include mask lines, and only if mask is enabled
boundingBox,
shouldPreserveMaskedArea
);
const maskBlob = await konvaNodeToBlob(maskStage, boundingBox);
const maskImageData = await konvaNodeToImageData(maskStage, boundingBox);
return {
baseBlob,
baseImageData,
maskBlob,
maskImageData,
};
};

View File

@ -1,24 +0,0 @@
import { areAnyPixelsBlack, getImageDataTransparency } from 'common/util/arrayBuffer';
import type { GenerationMode } from 'features/canvas/store/canvasTypes';
export const getCanvasGenerationMode = (baseImageData: ImageData, maskImageData: ImageData): GenerationMode => {
const { isPartiallyTransparent: baseIsPartiallyTransparent, isFullyTransparent: baseIsFullyTransparent } =
getImageDataTransparency(baseImageData.data);
// check mask for black
const doesMaskHaveBlackPixels = areAnyPixelsBlack(maskImageData.data);
if (baseIsPartiallyTransparent) {
if (baseIsFullyTransparent) {
return 'txt2img';
}
return 'outpaint';
} else {
if (doesMaskHaveBlackPixels) {
return 'inpaint';
}
return 'img2img';
}
};

View File

@ -1,81 +0,0 @@
export const getColoredMaskSVG = (color: string) => {
return `data:image/svg+xml;utf8,<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="60px" height="60px" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.5,0,0,0.5,0,0)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,2.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,7.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,10)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,12.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,15)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,17.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,20)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,22.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,25)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,27.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,30)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-2.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-7.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-10)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-12.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-15)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-17.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-20)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-22.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-25)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-27.5)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
<g transform="matrix(0.5,0,0,0.5,0,-30)">
<path d="M-3.5,63.5L64,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
</g>
</svg>`.replaceAll('black', color);
};

View File

@ -1,20 +0,0 @@
import { $canvasBaseLayer } from 'features/canvas/store/canvasNanostore';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
* Gets the canvas base layer blob, without bounding box
*/
export const getFullBaseLayerBlob = async () => {
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
return;
}
const clonedBaseLayer = canvasBaseLayer.clone();
clonedBaseLayer.scale({ x: 1, y: 1 });
return konvaNodeToBlob(clonedBaseLayer, clonedBaseLayer.getClientRect());
};

View File

@ -1,20 +0,0 @@
import type { Stage } from 'konva/lib/Stage';
const getScaledCursorPosition = (stage: Stage) => {
const pointerPosition = stage.getPointerPosition();
const stageTransform = stage.getAbsoluteTransform().copy();
if (!pointerPosition || !stageTransform) {
return;
}
const scaledCursorPosition = stageTransform.invert().point(pointerPosition);
return {
x: scaledCursorPosition.x,
y: scaledCursorPosition.y,
};
};
export default getScaledCursorPosition;

View File

@ -1,13 +0,0 @@
import { isInputElement } from 'common/util/isInputElement';
export const isInteractiveTarget = (target: EventTarget | null) => {
if (target instanceof HTMLElement) {
return (
target.tabIndex > -1 ||
isInputElement(target) ||
['dialog', 'alertdialog'].includes(target.getAttribute('role') ?? '')
);
}
return false;
};

View File

@ -1,14 +0,0 @@
import type Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { canvasToBlob } from './canvasToBlob';
/**
* Converts a Konva node to a Blob
* @param node - The Konva node to convert to a Blob
* @param boundingBox - The bounding box to crop to
* @returns A Promise that resolves with Blob of the node cropped to the bounding box
*/
export const konvaNodeToBlob = async (node: Konva.Node, boundingBox: IRect): Promise<Blob> => {
return await canvasToBlob(node.toCanvas(boundingBox));
};

View File

@ -1,17 +0,0 @@
import type Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { dataURLToImageData } from './dataURLToImageData';
/**
* Converts a Konva node to an ImageData object
* @param node - The Konva node to convert to an ImageData object
* @param boundingBox - The bounding box to crop to
* @returns A Promise that resolves with ImageData object of the node cropped to the bounding box
*/
export const konvaNodeToImageData = async (node: Konva.Node, boundingBox: IRect): Promise<ImageData> => {
// get a dataURL of the bbox'd region
const dataURL = node.toDataURL(boundingBox);
return await dataURLToImageData(dataURL, boundingBox.width, boundingBox.height);
};

View File

@ -1,5 +0,0 @@
const roundToHundreth = (val: number): number => {
return Math.round(val * 100) / 100;
};
export default roundToHundreth;

View File

@ -10,8 +10,8 @@ import {
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setShouldInvertBrushSizeScrollDirection } from 'features/canvas/store/canvasSlice';
import { RGGlobalOpacity } from 'features/controlLayers/components/RGGlobalOpacity';
import { MaskOpacity } from 'features/controlLayers/components/MaskOpacity';
import { invertScrollChanged } from 'features/controlLayers/store/canvasV2Slice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@ -22,7 +22,7 @@ const ControlLayersSettingsPopover = () => {
const dispatch = useAppDispatch();
const invertScroll = useAppSelector((s) => s.canvasV2.tool.invertScroll);
const onChangeInvertScroll = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldInvertBrushSizeScrollDirection(e.target.checked)),
(e: ChangeEvent<HTMLInputElement>) => dispatch(invertScrollChanged(e.target.checked)),
[dispatch]
);
return (
@ -33,7 +33,7 @@ const ControlLayersSettingsPopover = () => {
<PopoverContent>
<PopoverBody>
<Flex direction="column" gap={2}>
<RGGlobalOpacity />
<MaskOpacity />
<FormControl w="full">
<FormLabel flexGrow={1}>{t('unifiedCanvas.invertBrushSizeScrollDirection')}</FormLabel>
<Checkbox isChecked={invertScroll} onChange={onChangeInvertScroll} />

View File

@ -8,12 +8,17 @@ import { memo } from 'react';
export const HeadsUpDisplay = memo(() => {
const stageAttrs = useStore($stageAttrs);
const bbox = useAppSelector((s) => s.canvasV2.bbox);
const document = useAppSelector((s) => s.canvasV2.document);
return (
<Flex flexDir="column" bg="blackAlpha.400" borderBottomEndRadius="base" p={2} minW={64} gap={2}>
<HUDItem label="Scale" value={round(stageAttrs.scale, 3)} />
<HUDItem label="Document Size" value={`${document.width}×${document.height}`} />
<HUDItem label="Stage Pos" value={`${round(stageAttrs.x, 3)}, ${round(stageAttrs.y, 3)}`} />
<HUDItem label="Stage Size" value={`${round(stageAttrs.width, 3)}, ${round(stageAttrs.height, 3)}`} />
<HUDItem
label="Stage Size"
value={`${round(stageAttrs.width / stageAttrs.scale, 3)}, ${round(stageAttrs.height / stageAttrs.scale, 3)}`}
/>
<HUDItem label="BBox Size" value={`${bbox.width}×${bbox.height}`} />
<HUDItem label="BBox Position" value={`${bbox.x}, ${bbox.y}`} />
<HUDItem label="BBox Width % 8" value={round(bbox.width % 8, 3)} />

View File

@ -1,19 +1,19 @@
import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { rgGlobalOpacityChanged } from 'features/controlLayers/store/canvasV2Slice';
import { maskOpacityChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const marks = [0, 25, 50, 75, 100];
const formatPct = (v: number | string) => `${v} %`;
export const RGGlobalOpacity = memo(() => {
export const MaskOpacity = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const opacity = useAppSelector((s) => Math.round(s.canvasV2.maskFillOpacity * 100));
const opacity = useAppSelector((s) => Math.round(s.canvasV2.settings.maskOpacity * 100));
const onChange = useCallback(
(v: number) => {
dispatch(rgGlobalOpacityChanged({ opacity: v / 100 }));
dispatch(maskOpacityChanged(v / 100));
},
[dispatch]
);
@ -46,4 +46,4 @@ export const RGGlobalOpacity = memo(() => {
);
});
RGGlobalOpacity.displayName = 'RGGlobalOpacity';
MaskOpacity.displayName = 'MaskOpacity';

View File

@ -1,6 +1,6 @@
import { useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { rgbColorToString } from 'features/canvas/util/colorToString';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { RGHeader } from 'features/controlLayers/components/RegionalGuidance/RGHeader';
import { RGSettings } from 'features/controlLayers/components/RegionalGuidance/RGSettings';

View File

@ -1,8 +1,8 @@
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { stopPropagation } from 'common/util/stopPropagation';
import { rgbColorToString } from 'features/canvas/util/colorToString';
import { rgFillChanged } from 'features/controlLayers/store/canvasV2Slice';
import { selectRGOrThrow } from 'features/controlLayers/store/regionsReducers';
import { memo, useCallback } from 'react';

View File

@ -1,10 +1,10 @@
import { $alt, $ctrl, $meta, $shift, Box, Flex, Heading } from '@invoke-ai/ui-library';
import { $alt, $ctrl, $meta, $shift, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background';
import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers/layers';
import {
$bbox,
@ -70,7 +70,7 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
const regions = useAppSelector((s) => s.canvasV2.regions);
const tool = useAppSelector((s) => s.canvasV2.tool);
const selectedEntityIdentifier = useAppSelector((s) => s.canvasV2.selectedEntityIdentifier);
const maskFillOpacity = useAppSelector((s) => s.canvasV2.maskFillOpacity);
const maskOpacity = useAppSelector((s) => s.canvasV2.settings.maskOpacity);
const bbox = useAppSelector((s) => s.canvasV2.bbox);
const lastCursorPos = useStore($lastCursorPos);
const lastMouseDownPos = useStore($lastMouseDownPos);
@ -95,10 +95,10 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
const currentFill = useMemo(() => {
if (selectedEntity && selectedEntity.type === 'regional_guidance') {
return { ...selectedEntity.fill, a: maskFillOpacity };
return { ...selectedEntity.fill, a: maskOpacity };
}
return tool.fill;
}, [maskFillOpacity, selectedEntity, tool.fill]);
}, [maskOpacity, selectedEntity, tool.fill]);
const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
const dpr = useDevicePixelRatio({ round: false });
@ -106,7 +106,7 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
useLayoutEffect(() => {
$toolState.set(tool);
$selectedEntity.set(selectedEntity);
$bbox.set(bbox);
$bbox.set({ x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height });
$currentFill.set(currentFill);
}, [selectedEntity, tool, bbox, currentFill]);
@ -291,6 +291,7 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
const resizeObserver = new ResizeObserver(fitStageToContainer);
resizeObserver.observe(container);
fitStageToContainer();
renderBackgroundLayer(stage);
return () => {
resizeObserver.disconnect();
@ -352,23 +353,13 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
layers,
controlAdapters,
regions,
maskFillOpacity,
maskOpacity,
tool.selected,
selectedEntity,
getImageDTO,
onPosChanged
);
}, [
controlAdapters,
layers,
maskFillOpacity,
onPosChanged,
regions,
renderers,
selectedEntity,
stage,
tool.selected,
]);
}, [controlAdapters, layers, maskOpacity, onPosChanged, regions, renderers, selectedEntity, stage, tool.selected]);
// useLayoutEffect(() => {
// if (asPreview) {
@ -414,15 +405,6 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
return (
<Flex position="relative" w="full" h="full">
<Box
position="absolute"
w="full"
h="full"
borderRadius="base"
backgroundImage={TRANSPARENCY_CHECKER_PATTERN}
backgroundRepeat="repeat"
opacity={0.2}
/>
{!asPreview && <NoEntitiesFallback />}
<Flex
position="absolute"
@ -433,6 +415,9 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
ref={containerRef}
tabIndex={-1}
borderRadius="base"
border={1}
borderStyle="solid"
borderColor="base.700"
overflow="hidden"
data-testid="control-layers-canvas"
/>

View File

@ -23,7 +23,7 @@ export const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
/**
* The target spacing of individual points of brush strokes, as a percentage of the brush size.
*/
export const BRUSH_SPACING_PCT = 10;
export const BRUSH_SPACING_TARGET_SCALE = 0.1;
/**
* The minimum brush spacing in pixels.
@ -54,3 +54,13 @@ export const MIN_CANVAS_SCALE = 0.1;
* Maximum (furthest-zoomed-in) scale
*/
export const MAX_CANVAS_SCALE = 20;
/**
* The fine grid size of the canvas
*/
export const CANVAS_GRID_SIZE_FINE = 8;
/**
* The coarse grid size of the canvas
*/
export const CANVAS_GRID_SIZE_COARSE = 64;

View File

@ -1,5 +1,4 @@
import { calculateNewBrushSize } from 'features/canvas/hooks/useCanvasZoom';
import { CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/canvas/util/constants';
import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background';
import { getScaledFlooredCursorPosition } from 'features/controlLayers/konva/util';
import type {
BrushLineAddedArg,
@ -16,6 +15,14 @@ import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types';
import { clamp } from 'lodash-es';
import {
BRUSH_SPACING_TARGET_SCALE,
CANVAS_SCALE_BY,
MAX_BRUSH_SPACING_PX,
MAX_CANVAS_SCALE,
MIN_BRUSH_SPACING_PX,
MIN_CANVAS_SCALE,
} from './constants';
import { PREVIEW_TOOL_GROUP_ID } from './naming';
type Arg = {
@ -60,6 +67,18 @@ const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: Arg['setLastC
return pos;
};
const calculateNewBrushSize = (brushSize: number, delta: number) => {
// This equation was derived by fitting a curve to the desired brush sizes and deltas
// see https://github.com/invoke-ai/InvokeAI/pull/5542#issuecomment-1915847565
const targetDelta = Math.sign(delta) * 0.7363 * Math.pow(1.0394, brushSize);
// This needs to be clamped to prevent the delta from getting too large
const finalDelta = clamp(targetDelta, -20, 20);
// The new brush size is also clamped to prevent it from getting too large or small
const newBrushSize = clamp(brushSize + finalDelta, 1, 500);
return newBrushSize;
};
/**
* Adds the next point to a line if the cursor has moved far enough from the last point.
* @param layerId The layer to (maybe) add the point to
@ -82,7 +101,13 @@ const maybeAddNextPoint = (
// Continue the last line
const lastAddedPoint = getLastAddedPoint();
const toolState = getToolState();
const minSpacingPx = toolState.selected === 'brush' ? toolState.brush.width * 0.05 : toolState.eraser.width * 0.05;
const minSpacingPx = clamp(
toolState.selected === 'brush'
? toolState.brush.width * BRUSH_SPACING_TARGET_SCALE
: toolState.eraser.width * BRUSH_SPACING_TARGET_SCALE,
MIN_BRUSH_SPACING_PX,
MAX_BRUSH_SPACING_PX
);
if (lastAddedPoint) {
// Dispatching redux events impacts perf substantially - using brush spacing keeps dispatches to a reasonable number
if (Math.hypot(lastAddedPoint.x - currentPos.x, lastAddedPoint.y - currentPos.y) < minSpacingPx) {
@ -354,6 +379,7 @@ export const setStageEventHandlers = ({
}
});
//#region wheel
stage.on('wheel', (e) => {
e.evt.preventDefault();
@ -393,9 +419,11 @@ export const setStageEventHandlers = ({
stage.scaleY(newScale);
stage.position(newPos);
setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale });
renderBackgroundLayer(stage);
}
});
//#region dragmove
stage.on('dragmove', () => {
setStageAttrs({
x: stage.x(),
@ -404,21 +432,22 @@ export const setStageEventHandlers = ({
height: stage.height(),
scale: stage.scaleX(),
});
renderBackgroundLayer(stage);
});
//#region dragend
stage.on('dragend', () => {
// Stage position should always be an integer, else we get fractional pixels which are blurry
stage.x(Math.floor(stage.x()));
stage.y(Math.floor(stage.y()));
setStageAttrs({
x: stage.x(),
y: stage.y(),
x: Math.floor(stage.x()),
y: Math.floor(stage.y()),
width: stage.width(),
height: stage.height(),
scale: stage.scaleX(),
});
});
//#region key
const onKeyDown = (e: KeyboardEvent) => {
if (e.repeat) {
return;

View File

@ -0,0 +1,115 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import Konva from 'konva';
const baseGridLineColor = getArbitraryBaseColor(27);
const fineGridLineColor = getArbitraryBaseColor(18);
const getGridSpacing = (scale: number): number => {
if (scale >= 2) {
return 8;
}
if (scale >= 1 && scale < 2) {
return 16;
}
if (scale >= 0.5 && scale < 1) {
return 32;
}
if (scale >= 0.25 && scale < 0.5) {
return 64;
}
if (scale >= 0.125 && scale < 0.25) {
return 128;
}
return 256;
};
const getBackgroundLayer = (stage: Konva.Stage): Konva.Layer => {
let background = stage.findOne<Konva.Layer>('#background');
if (background) {
return background;
}
background = new Konva.Layer({ id: 'background' });
stage.add(background);
return background;
};
export const renderBackgroundLayer = (stage: Konva.Stage): void => {
const background = getBackgroundLayer(stage);
background.zIndex(0);
const scale = stage.scaleX();
const gridSpacing = getGridSpacing(scale);
const x = stage.x();
const y = stage.y();
const width = stage.width();
const height = stage.height();
const stageRect = {
x1: 0,
y1: 0,
x2: width,
y2: height,
offset: {
x: x / scale,
y: y / scale,
},
};
const gridOffset = {
x: Math.ceil(x / scale / gridSpacing) * gridSpacing,
y: Math.ceil(y / scale / gridSpacing) * gridSpacing,
};
const gridRect = {
x1: -gridOffset.x,
y1: -gridOffset.y,
x2: width / scale - gridOffset.x + gridSpacing,
y2: height / scale - gridOffset.y + gridSpacing,
};
const gridFullRect = {
x1: Math.min(stageRect.x1, gridRect.x1),
y1: Math.min(stageRect.y1, gridRect.y1),
x2: Math.max(stageRect.x2, gridRect.x2),
y2: Math.max(stageRect.y2, gridRect.y2),
};
const // find the x & y size of the grid
xSize = gridFullRect.x2 - gridFullRect.x1;
const ySize = gridFullRect.y2 - gridFullRect.y1;
// compute the number of steps required on each axis.
const xSteps = Math.round(xSize / gridSpacing) + 1;
const ySteps = Math.round(ySize / gridSpacing) + 1;
const strokeWidth = 1 / scale;
let _x = 0;
let _y = 0;
background.destroyChildren();
for (let i = 0; i < xSteps; i++) {
_x = gridFullRect.x1 + i * gridSpacing;
background.add(
new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? fineGridLineColor : baseGridLineColor,
strokeWidth,
listening: false,
})
);
}
for (let i = 0; i < ySteps; i++) {
_y = gridFullRect.y1 + i * gridSpacing;
background.add(
new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? fineGridLineColor : baseGridLineColor,
strokeWidth,
listening: false,
})
);
}
};

View File

@ -1,5 +1,4 @@
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { imageDataToDataURL } from 'features/canvas/util/blobToDataURL';
import {
CA_LAYER_IMAGE_NAME,
LAYER_BBOX_NAME,
@ -7,6 +6,7 @@ import {
RG_LAYER_OBJECT_GROUP_NAME,
} from 'features/controlLayers/konva/naming';
import { createBboxRect } from 'features/controlLayers/konva/renderers/objects';
import { imageDataToDataURL } from "features/controlLayers/konva/util";
import type { ControlAdapterData, LayerData, RegionalGuidanceData } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';

View File

@ -50,7 +50,7 @@ const renderLayers = (
}
}
// We'll need to ensure the tool preview layer is on top of the rest of the layers
let zIndex = 0;
let zIndex = 1;
for (const layer of layers) {
renderRasterLayer(stage, layer, tool, zIndex, onPosChanged);
zIndex++;

View File

@ -1,17 +1,11 @@
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import {
getLayerBboxId,
getObjectGroupId,
LAYER_BBOX_NAME,
PREVIEW_GENERATION_BBOX_DUMMY_RECT,
} from 'features/controlLayers/konva/naming';
import type {
BrushLine,
CanvasEntity,
EraserLine,
ImageObject,
RectShape,
} from 'features/controlLayers/store/types';
import type { BrushLine, CanvasEntity, EraserLine, ImageObject, RectShape } from 'features/controlLayers/store/types';
import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types';
import { t } from 'i18next';
import Konva from 'konva';

View File

@ -1,10 +1,6 @@
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import {
BBOX_SELECTED_STROKE,
BRUSH_BORDER_INNER_COLOR,
BRUSH_BORDER_OUTER_COLOR,
} from 'features/controlLayers/konva/constants';
import { BRUSH_BORDER_INNER_COLOR, BRUSH_BORDER_OUTER_COLOR } from 'features/controlLayers/konva/constants';
import {
PREVIEW_BRUSH_BORDER_INNER_ID,
PREVIEW_BRUSH_BORDER_OUTER_ID,
@ -206,9 +202,10 @@ export const getBboxPreviewGroup = (
height,
};
// Here we _could_ go ahead and update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
// However, we have another function that renders the bbox when its internal state changes, so we will rely on that
// to set the new attrs.
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
// TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly.
// Gotta be a way to avoid setting it twice...
bboxRect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
onBboxTransformed(bbox);
@ -281,7 +278,7 @@ export const getToolPreviewGroup = (stage: Konva.Stage): Konva.Group => {
if (toolPreviewGroup) {
return toolPreviewGroup;
}
const scale = stage.scaleX();
toolPreviewGroup = new Konva.Group({ id: PREVIEW_TOOL_GROUP_ID });
// Create the brush preview group & circles
@ -296,7 +293,7 @@ export const getToolPreviewGroup = (stage: Konva.Stage): Konva.Group => {
id: PREVIEW_BRUSH_BORDER_INNER_ID,
listening: false,
stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: 1,
strokeWidth: 1 / scale,
strokeEnabled: true,
});
brushPreviewGroup.add(brushPreviewBorderInner);
@ -304,7 +301,7 @@ export const getToolPreviewGroup = (stage: Konva.Stage): Konva.Group => {
id: PREVIEW_BRUSH_BORDER_OUTER_ID,
listening: false,
stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: 1,
strokeWidth: 1 / scale,
strokeEnabled: true,
});
brushPreviewGroup.add(brushPreviewBorderOuter);
@ -313,8 +310,7 @@ export const getToolPreviewGroup = (stage: Konva.Stage): Konva.Group => {
const rectPreview = new Konva.Rect({
id: PREVIEW_RECT_ID,
listening: false,
stroke: BBOX_SELECTED_STROKE,
strokeWidth: 1,
strokeEnabled: false,
});
toolPreviewGroup.add(rectPreview);

View File

@ -1,4 +1,4 @@
import { rgbColorToString } from 'features/canvas/util/colorToString';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { BBOX_SELECTED_STROKE } from 'features/controlLayers/konva/constants';
import {
COMPOSITING_RECT_NAME,

View File

@ -12,11 +12,11 @@ import {
RG_LAYER_NAME,
RG_LAYER_RECT_SHAPE_NAME,
} from 'features/controlLayers/konva/naming';
import type Konva from 'konva';
import type { RgbaColor } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import type { IRect, Vector2d } from 'konva/lib/types';
//#region getScaledFlooredCursorPosition
/**
* Gets the scaled and floored cursor position on the stage. If the cursor is not currently over the stage, returns null.
* @param stage The konva stage
@ -33,9 +33,7 @@ export const getScaledFlooredCursorPosition = (stage: Konva.Stage): Vector2d | n
y: Math.floor(scaledCursorPosition.y),
};
};
//#endregion
//#region snapPosToStage
/**
* Snaps a position to the edge of the stage if within a threshold of the edge
* @param pos The position to snap
@ -62,25 +60,19 @@ export const snapPosToStage = (pos: Vector2d, stage: Konva.Stage, snapPx = 10):
}
return snappedPos;
};
//#endregion
//#region getIsMouseDown
/**
* Checks if the left mouse button is currently pressed
* @param e The konva event
*/
export const getIsMouseDown = (e: KonvaEventObject<MouseEvent>): boolean => e.evt.buttons === 1;
//#endregion
//#region getIsFocused
/**
* Checks if the stage is currently focused
* @param stage The konva stage
*/
export const getIsFocused = (stage: Konva.Stage): boolean => stage.container().contains(document.activeElement);
//#endregion
//#region mapId
/**
* Simple util to map an object to its id property. Serves as a minor optimization to avoid recreating a map callback
* every time we need to map an object to its id, which happens very often.
@ -88,9 +80,7 @@ export const getIsFocused = (stage: Konva.Stage): boolean => stage.container().c
* @returns The object's id property
*/
export const mapId = (object: { id: string }): string => object.id;
//#endregion
//#region konva selector callbacks
/**
* Konva selection callback to select all renderable layers. This includes RG, CA II and Raster layers.
* This can be provided to the `find` or `findOne` konva node methods.
@ -120,4 +110,141 @@ export const selectRasterObjects = (node: Konva.Node): boolean =>
node.name() === RASTER_LAYER_ERASER_LINE_NAME ||
node.name() === RASTER_LAYER_RECT_SHAPE_NAME ||
node.name() === RASTER_LAYER_IMAGE_NAME;
//#endregion
/**
* Convert a Blob to a data URL.
*/
export const blobToDataURL = (blob: Blob): Promise<string> => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (_e) => resolve(reader.result as string);
reader.onerror = (_e) => reject(reader.error);
reader.onabort = (_e) => reject(new Error('Read aborted'));
reader.readAsDataURL(blob);
});
};
/**
* Convert an ImageData object to a data URL.
*/
export function imageDataToDataURL(imageData: ImageData): string {
const { width, height } = imageData;
// Create a canvas to transfer the ImageData to
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw the ImageData onto the canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Unable to get canvas context');
}
ctx.putImageData(imageData, 0, 0);
// Convert the canvas to a data URL (base64)
return canvas.toDataURL();
}
/**
* Download a Blob as a file
*/
export const downloadBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
a.remove();
};
/**
* Gets a Blob from a HTMLCanvasElement.
*/
export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject('Unable to create Blob');
});
});
};
/**
* Gets an ImageData object from an image dataURL by drawing it to a canvas.
*/
export const dataURLToImageData = async (dataURL: string, width: number, height: number): Promise<ImageData> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = new Image();
if (!ctx) {
canvas.remove();
reject('Unable to get context');
return;
}
image.onload = function () {
ctx.drawImage(image, 0, 0);
canvas.remove();
resolve(ctx.getImageData(0, 0, width, height));
};
image.src = dataURL;
});
};
/**
* Converts a Konva node to a Blob
* @param node - The Konva node to convert to a Blob
* @param boundingBox - The bounding box to crop to
* @returns A Promise that resolves with Blob of the node cropped to the bounding box
*/
export const konvaNodeToBlob = async (node: Konva.Node, boundingBox: IRect): Promise<Blob> => {
return await canvasToBlob(node.toCanvas(boundingBox));
};
/**
* Converts a Konva node to an ImageData object
* @param node - The Konva node to convert to an ImageData object
* @param boundingBox - The bounding box to crop to
* @returns A Promise that resolves with ImageData object of the node cropped to the bounding box
*/
export const konvaNodeToImageData = async (node: Konva.Node, boundingBox: IRect): Promise<ImageData> => {
// get a dataURL of the bbox'd region
const dataURL = node.toDataURL(boundingBox);
return await dataURLToImageData(dataURL, boundingBox.width, boundingBox.height);
};
/**
* Gets the pixel under the cursor on the stage, or null if the cursor is not over the stage.
* @param stage The konva stage
*/
export const getPixelUnderCursor = (stage: Konva.Stage): RgbaColor | null => {
const cursorPos = stage.getPointerPosition();
const pixelRatio = Konva.pixelRatio;
if (!cursorPos) {
return null;
}
const ctx = stage.toCanvas().getContext('2d');
if (!ctx) {
return null;
}
const [r, g, b, a] = ctx.getImageData(cursorPos.x * pixelRatio, cursorPos.y * pixelRatio, 1, 1).data;
if (r === undefined || g === undefined || b === undefined || a === undefined) {
return null;
}
return { r, g, b, a };
};

View File

@ -0,0 +1,39 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import type { BoundingBoxScaleMethod, CanvasV2State, Dimensions } from 'features/controlLayers/store/types';
import { getScaledBoundingBoxDimensions } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect } from 'konva/lib/types';
export const bboxReducers = {
scaledBboxChanged: (state, action: PayloadAction<Partial<Dimensions>>) => {
const { width, height } = action.payload;
state.bbox.scaledWidth = width ?? state.bbox.scaledWidth;
state.bbox.scaledHeight = height ?? state.bbox.scaledHeight;
},
bboxScaleMethodChanged: (state, action: PayloadAction<BoundingBoxScaleMethod>) => {
state.bbox.scaleMethod = action.payload;
if (action.payload === 'auto') {
const bboxDims = { width: state.bbox.width, height: state.bbox.height };
const optimalDimension = getOptimalDimension(state.params.model);
const scaledBboxDims = getScaledBoundingBoxDimensions(bboxDims, optimalDimension);
state.bbox.scaledWidth = scaledBboxDims.width;
state.bbox.scaledHeight = scaledBboxDims.height;
}
},
bboxChanged: (state, action: PayloadAction<IRect>) => {
const { x, y, width, height } = action.payload;
state.bbox.x = x;
state.bbox.y = y;
state.bbox.width = width;
state.bbox.height = height;
if (state.bbox.scaleMethod === 'auto') {
const bboxDims = { width: state.bbox.width, height: state.bbox.height };
const optimalDimension = getOptimalDimension(state.params.model);
const scaledBboxDims = getScaledBoundingBoxDimensions(bboxDims, optimalDimension);
state.bbox.scaledWidth = scaledBboxDims.width;
state.bbox.scaledHeight = scaledBboxDims.height;
}
},
} satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -3,23 +3,30 @@ import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
import { bboxReducers } from 'features/controlLayers/store/bboxReducers';
import { compositingReducers } from 'features/controlLayers/store/compositingReducers';
import { controlAdaptersReducers } from 'features/controlLayers/store/controlAdaptersReducers';
import { ipAdaptersReducers } from 'features/controlLayers/store/ipAdaptersReducers';
import { layersReducers } from 'features/controlLayers/store/layersReducers';
import { paramsReducers } from 'features/controlLayers/store/paramsReducers';
import { regionsReducers } from 'features/controlLayers/store/regionsReducers';
import { settingsReducers } from 'features/controlLayers/store/settingsReducers';
import { toolReducers } from 'features/controlLayers/store/toolReducers';
import { initialAspectRatioState } from 'features/parameters/components/ImageSize/constants';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import type { IRect, Vector2d } from 'konva/lib/types';
import { atom } from 'nanostores';
import type { CanvasEntity, CanvasEntityIdentifier, CanvasV2State, RgbaColor, StageAttrs, Tool } from './types';
import type { CanvasEntity, CanvasEntityIdentifier, CanvasV2State, RgbaColor, StageAttrs } from './types';
import { DEFAULT_RGBA_COLOR } from './types';
const initialState: CanvasV2State = {
_version: 3,
selectedEntityIdentifier: null,
layers: [],
controlAdapters: [],
ipAdapters: [],
regions: [],
tool: {
selected: 'bbox',
selectedBuffer: null,
@ -42,17 +49,20 @@ const initialState: CanvasV2State = {
y: 0,
width: 512,
height: 512,
},
scaledBbox: {
width: 512,
height: 512,
scaleMethod: 'auto',
scaledWidth: 512,
scaledHeight: 512,
},
settings: {
maskOpacity: 0.3,
// TODO(psyche): These are copied from old canvas state, need to be implemented
autoSave: false,
imageSmoothing: true,
preserveMaskedArea: false,
showHUD: true,
clipToBbox: false,
cropToBboxOnSave: false,
},
controlAdapters: [],
ipAdapters: [],
regions: [],
layers: [],
maskFillOpacity: 0.3,
compositing: {
maskBlur: 16,
maskBlurMethod: 'box',
@ -105,6 +115,9 @@ export const canvasV2Slice = createSlice({
...regionsReducers,
...paramsReducers,
...compositingReducers,
...settingsReducers,
...toolReducers,
...bboxReducers,
widthChanged: (state, action: PayloadAction<{ width: number; updateAspectRatio?: boolean; clamp?: boolean }>) => {
const { width, updateAspectRatio, clamp } = action.payload;
state.document.width = clamp ? Math.max(roundDownToMultiple(width, 8), 64) : width;
@ -126,30 +139,6 @@ export const canvasV2Slice = createSlice({
aspectRatioChanged: (state, action: PayloadAction<AspectRatioState>) => {
state.document.aspectRatio = action.payload;
},
bboxChanged: (state, action: PayloadAction<IRect>) => {
state.bbox = action.payload;
},
brushWidthChanged: (state, action: PayloadAction<number>) => {
state.tool.brush.width = Math.round(action.payload);
},
eraserWidthChanged: (state, action: PayloadAction<number>) => {
state.tool.eraser.width = Math.round(action.payload);
},
fillChanged: (state, action: PayloadAction<RgbaColor>) => {
state.tool.fill = action.payload;
},
invertScrollChanged: (state, action: PayloadAction<boolean>) => {
state.tool.invertScroll = action.payload;
},
toolChanged: (state, action: PayloadAction<Tool>) => {
state.tool.selected = action.payload;
},
toolBufferChanged: (state, action: PayloadAction<Tool | null>) => {
state.tool.selectedBuffer = action.payload;
},
maskFillOpacityChanged: (state, action: PayloadAction<number>) => {
state.maskFillOpacity = action.payload;
},
entitySelected: (state, action: PayloadAction<CanvasEntityIdentifier>) => {
state.selectedEntityIdentifier = action.payload;
},
@ -173,9 +162,11 @@ export const {
invertScrollChanged,
toolChanged,
toolBufferChanged,
maskFillOpacityChanged,
maskOpacityChanged,
entitySelected,
allEntitiesDeleted,
scaledBboxChanged,
bboxScaleMethodChanged,
// layers
layerAdded,
layerRecalled,
@ -238,7 +229,6 @@ export const {
rgBboxChanged,
rgDeleted,
rgAllDeleted,
rgGlobalOpacityChanged,
rgMovedForwardOne,
rgMovedToFront,
rgMovedBackwardOne,

View File

@ -1,6 +1,6 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import getScaledBoundingBoxDimensions from 'features/canvas/util/getScaledBoundingBoxDimensions';
import type { CanvasV2State } from 'features/controlLayers/store/types';
import { getScaledBoundingBoxDimensions } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { CLIP_SKIP_MAP } from 'features/parameters/types/constants';
import type {
@ -67,10 +67,10 @@ export const paramsReducers = {
state.bbox.width = bboxDims.width;
state.bbox.height = bboxDims.height;
if (state.scaledBbox.scaleMethod === 'auto') {
if (state.bbox.scaleMethod === 'auto') {
const scaledBboxDims = getScaledBoundingBoxDimensions(bboxDims, optimalDimension);
state.scaledBbox.width = scaledBboxDims.width;
state.scaledBbox.height = scaledBboxDims.height;
state.bbox.scaledWidth = scaledBboxDims.width;
state.bbox.scaledHeight = scaledBboxDims.height;
}
}

View File

@ -120,10 +120,6 @@ export const regionsReducers = {
rgAllDeleted: (state) => {
state.regions = [];
},
rgGlobalOpacityChanged: (state, action: PayloadAction<{ opacity: number }>) => {
const { opacity } = action.payload;
state.maskFillOpacity = opacity;
},
rgMovedForwardOne: (state, action: PayloadAction<{ id: string }>) => {
const { id } = action.payload;
const rg = selectRG(state, id);

View File

@ -1,13 +1,13 @@
import { createSelector } from '@reduxjs/toolkit';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
export const selectEntityCount = createSelector(selectCanvasSlice, (canvasV2) => {
export const selectEntityCount = createSelector(selectCanvasV2Slice, (canvasV2) => {
return (
canvasV2.regions.length + canvasV2.controlAdapters.length + canvasV2.ipAdapters.length + canvasV2.layers.length
);
});
export const selectOptimalDimension = createSelector(selectCanvasSlice, (canvasV2) => {
export const selectOptimalDimension = createSelector(selectCanvasV2Slice, (canvasV2) => {
return getOptimalDimension(canvasV2.params.model);
});

View File

@ -0,0 +1,8 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import type { CanvasV2State } from 'features/controlLayers/store/types';
export const settingsReducers = {
maskOpacityChanged: (state, action: PayloadAction<number>) => {
state.settings.maskOpacity = action.payload;
},
} satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -1,57 +0,0 @@
import type { ActionReducerMapBuilder, PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
type MySlice = {
flavour: 'vanilla' | 'chocolate' | 'strawberry';
sprinkles: boolean;
customers: { id: string; name: string }[];
};
const initialStateMySlice: MySlice = { flavour: 'vanilla', sprinkles: false, customers: [] };
const reducersInAnotherFile: SliceCaseReducers<MySlice> = {
sprinklesToggled: (state) => {
state.sprinkles = !state.sprinkles;
},
customerAdded: {
reducer: (state, action: PayloadAction<{ id: string; name: string }>) => {
state.customers.push(action.payload);
},
prepare: (name: string) => ({ payload: { name, id: crypto.randomUUID() } }),
},
};
const extraReducersInAnotherFile = (builder: ActionReducerMapBuilder<MySlice>) => {
builder.addCase(otherSlice.actions.fooChanged, (state, action) => {
if (action.payload === 'bar') {
state.flavour = 'vanilla';
}
});
};
export const mySlice = createSlice({
name: 'mySlice',
initialState: initialStateMySlice,
reducers: {
...reducersInAnotherFile,
flavourChanged: (state, action: PayloadAction<MySlice['flavour']>) => {
state.flavour = action.payload;
},
},
extraReducers: extraReducersInAnotherFile,
});
type OtherSlice = {
something: string;
};
const initialStateOtherSlice: OtherSlice = { something: 'foo' };
export const otherSlice = createSlice({
name: 'otherSlice',
initialState: initialStateOtherSlice,
reducers: {
fooChanged: (state, action: PayloadAction<string>) => {
state.something = action.payload;
},
},
});

View File

@ -0,0 +1,23 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import type { CanvasV2State, RgbaColor, Tool } from 'features/controlLayers/store/types';
export const toolReducers = {
brushWidthChanged: (state, action: PayloadAction<number>) => {
state.tool.brush.width = Math.round(action.payload);
},
eraserWidthChanged: (state, action: PayloadAction<number>) => {
state.tool.eraser.width = Math.round(action.payload);
},
fillChanged: (state, action: PayloadAction<RgbaColor>) => {
state.tool.fill = action.payload;
},
invertScrollChanged: (state, action: PayloadAction<boolean>) => {
state.tool.invertScroll = action.payload;
},
toolChanged: (state, action: PayloadAction<Tool>) => {
state.tool.selected = action.payload;
},
toolBufferChanged: (state, action: PayloadAction<Tool | null>) => {
state.tool.selectedBuffer = action.payload;
},
} satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -802,6 +802,10 @@ export type Dimensions = {
export type CanvasV2State = {
_version: 3;
selectedEntityIdentifier: CanvasEntityIdentifier | null;
layers: LayerData[];
controlAdapters: ControlAdapterData[];
ipAdapters: IPAdapterData[];
regions: RegionalGuidanceData[];
tool: {
selected: Tool;
selectedBuffer: Tool | null;
@ -815,17 +819,24 @@ export type CanvasV2State = {
height: ParameterHeight;
aspectRatio: AspectRatioState;
};
bbox: IRect;
scaledBbox: {
scaleMethod: BoundingBoxScaleMethod;
settings: {
imageSmoothing: boolean;
maskOpacity: number;
showHUD: boolean;
autoSave: boolean;
preserveMaskedArea: boolean;
cropToBboxOnSave: boolean;
clipToBbox: boolean;
};
bbox: {
x: number;
y: number;
width: ParameterWidth;
height: ParameterHeight;
scaleMethod: BoundingBoxScaleMethod;
scaledWidth: ParameterWidth;
scaledHeight: ParameterHeight;
};
layers: LayerData[];
controlAdapters: ControlAdapterData[];
ipAdapters: IPAdapterData[];
regions: RegionalGuidanceData[];
maskFillOpacity: number;
compositing: {
maskBlur: number;
maskBlurMethod: ParameterMaskBlurMethod;

View File

@ -1,8 +1,14 @@
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import type { Dimensions } from 'features/canvas/store/canvasTypes';
import { CANVAS_GRID_SIZE_FINE } from 'features/canvas/store/constants';
import { CANVAS_GRID_SIZE_FINE } from 'features/controlLayers/konva/constants';
import type { Dimensions } from 'features/controlLayers/store/types';
const getScaledBoundingBoxDimensions = (dimensions: Dimensions, optimalDimension: number) => {
/**
* Scales the bounding box dimensions to the optimal dimension. The optimal dimensions should be the trained dimension
* for the model. For example, 1024 for SDXL or 512 for SD1.5.
* @param dimensions The un-scaled bbox dimensions
* @param optimalDimension The optimal dimension to scale the bbox to
*/
export const getScaledBoundingBoxDimensions = (dimensions: Dimensions, optimalDimension: number): Dimensions => {
const { width, height } = dimensions;
const scaledDimensions = { width, height };
@ -30,5 +36,3 @@ const getScaledBoundingBoxDimensions = (dimensions: Dimensions, optimalDimension
return scaledDimensions;
};
export default getScaledBoundingBoxDimensions;

View File

@ -1,7 +1,7 @@
import { getStore } from 'app/store/nanostores/store';
import { deepClone } from 'common/util/deepClone';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { blobToDataURL } from "features/controlLayers/konva/util";
import { RG_LAYER_NAME } from 'features/controlLayers/konva/naming';
import { renderers } from 'features/controlLayers/konva/renderers/layers';
import { rgMaskImageUploaded } from 'features/controlLayers/store/canvasV2Slice';

View File

@ -2,17 +2,15 @@ import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { setBoundingBoxScaleMethod } from 'features/canvas/store/canvasSlice';
import { isBoundingBoxScaleMethod } from 'features/canvas/store/canvasTypes';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { bboxScaleMethodChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isBoundingBoxScaleMethod } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const ParamScaleBeforeProcessing = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const boundingBoxScaleMethod = useAppSelector((s) => s.canvasV2.scaledBbox.scaleMethod);
const optimalDimension = useAppSelector(selectOptimalDimension);
const scaleMethod = useAppSelector((s) => s.canvasV2.bbox.scaleMethod);
const OPTIONS: ComboboxOption[] = useMemo(
() => [
@ -28,15 +26,12 @@ const ParamScaleBeforeProcessing = () => {
if (!isBoundingBoxScaleMethod(v?.value)) {
return;
}
dispatch(setBoundingBoxScaleMethod(v.value, optimalDimension));
dispatch(bboxScaleMethodChanged(v.value));
},
[dispatch, optimalDimension]
[dispatch]
);
const value = useMemo(
() => OPTIONS.find((o) => o.value === boundingBoxScaleMethod),
[boundingBoxScaleMethod, OPTIONS]
);
const value = useMemo(() => OPTIONS.find((o) => o.value === scaleMethod), [scaleMethod, OPTIONS]);
return (
<FormControl>

View File

@ -1,6 +1,6 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setScaledBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
import { scaledBboxChanged } from 'features/controlLayers/store/canvasV2Slice';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@ -9,8 +9,8 @@ const ParamScaledHeight = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const optimalDimension = useAppSelector(selectOptimalDimension);
const isManual = useAppSelector((s) => s.canvasV2.scaledBbox.scaleMethod === 'manual');
const height = useAppSelector((s) => s.canvasV2.scaledBbox.height);
const isManual = useAppSelector((s) => s.canvasV2.bbox.scaleMethod === 'manual');
const height = useAppSelector((s) => s.canvasV2.bbox.scaledHeight);
const sliderMin = useAppSelector((s) => s.config.sd.scaledBoundingBoxHeight.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.scaledBoundingBoxHeight.sliderMax);
const numberInputMin = useAppSelector((s) => s.config.sd.scaledBoundingBoxHeight.numberInputMin);
@ -20,7 +20,7 @@ const ParamScaledHeight = () => {
const onChange = useCallback(
(height: number) => {
dispatch(setScaledBoundingBoxDimensions({ height }));
dispatch(scaledBboxChanged({ height }));
},
[dispatch]
);

View File

@ -1,6 +1,6 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { setScaledBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
import { scaledBboxChanged } from 'features/controlLayers/store/canvasV2Slice';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@ -9,8 +9,8 @@ const ParamScaledWidth = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const optimalDimension = useAppSelector(selectOptimalDimension);
const isManual = useAppSelector((s) => s.canvasV2.scaledBbox.scaleMethod === 'manual');
const width = useAppSelector((s) => s.canvasV2.scaledBbox.width);
const isManual = useAppSelector((s) => s.canvasV2.bbox.scaleMethod === 'manual');
const width = useAppSelector((s) => s.canvasV2.bbox.scaledWidth);
const sliderMin = useAppSelector((s) => s.config.sd.scaledBoundingBoxWidth.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.scaledBoundingBoxWidth.sliderMax);
const numberInputMin = useAppSelector((s) => s.config.sd.scaledBoundingBoxWidth.numberInputMin);
@ -19,7 +19,7 @@ const ParamScaledWidth = () => {
const fineStep = useAppSelector((s) => s.config.sd.scaledBoundingBoxWidth.fineStep);
const onChange = useCallback(
(width: number) => {
dispatch(setScaledBoundingBoxDimensions({ width }));
dispatch(scaledBboxChanged({ width }));
},
[dispatch]
);