mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
refactor(ui): port remaining canvasV1 rendering logic to V2, remove old code
This commit is contained in:
parent
7ef4553fc9
commit
5184d05bc2
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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[] = [];
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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;
|
@ -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;
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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');
|
@ -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);
|
@ -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
|
||||
);
|
@ -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;
|
||||
};
|
@ -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';
|
@ -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';
|
@ -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();
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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');
|
||||
});
|
||||
});
|
@ -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})`;
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
});
|
@ -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();
|
||||
};
|
@ -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;
|
@ -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);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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';
|
||||
}
|
||||
};
|
@ -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);
|
||||
};
|
@ -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());
|
||||
};
|
@ -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;
|
@ -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;
|
||||
};
|
@ -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));
|
||||
};
|
@ -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);
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
const roundToHundreth = (val: number): number => {
|
||||
return Math.round(val * 100) / 100;
|
||||
};
|
||||
|
||||
export default roundToHundreth;
|
@ -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} />
|
||||
|
@ -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)} />
|
||||
|
@ -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';
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
@ -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';
|
||||
|
@ -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++;
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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>;
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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>;
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
@ -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>;
|
@ -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;
|
||||
|
@ -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;
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
);
|
||||
|
@ -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]
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user