From 5ab345ee63cc6575ce066186f681c9687781d01f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 8 Jun 2024 10:51:30 +1000 Subject: [PATCH] feat(ui): wip generation bbox --- .../components/StageComponent.tsx | 151 ++++----- .../features/controlLayers/konva/events.ts | 16 +- .../features/controlLayers/konva/naming.ts | 19 +- .../controlLayers/konva/renderers/caLayer.ts | 31 +- .../controlLayers/konva/renderers/iiLayer.ts | 6 + .../controlLayers/konva/renderers/layers.ts | 24 +- .../controlLayers/konva/renderers/objects.ts | 4 +- .../konva/renderers/previewLayer.ts | 301 ++++++++++++++++++ .../konva/renderers/rasterLayer.ts | 2 + .../controlLayers/konva/renderers/rgLayer.ts | 2 + .../konva/renderers/toolPreview.ts | 212 ------------ .../controlLayers/store/controlLayersSlice.ts | 10 + .../src/features/controlLayers/store/types.ts | 2 + 13 files changed, 445 insertions(+), 335 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/previewLayer.ts delete mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx index ac12412389..ab64063e15 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx @@ -12,11 +12,12 @@ import { } from 'features/controlLayers/konva/constants'; import { setStageEventHandlers } from 'features/controlLayers/konva/events'; import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers/layers'; -import { renderImageDimsPreview } from 'features/controlLayers/konva/renderers/toolPreview'; +import { renderImageDimsPreview } from 'features/controlLayers/konva/renderers/previewLayer'; import { $brushColor, $brushSize, $brushSpacingPx, + $genBbox, $isDrawing, $isMouseDown, $isSpaceDown, @@ -29,6 +30,7 @@ import { $stageScale, $tool, $toolBuffer, + bboxChanged, brushLineAdded, brushSizeChanged, eraserLineAdded, @@ -80,12 +82,7 @@ const selectLayerCount = createSelector( (controlLayers) => controlLayers.present.layers.length ); -const useStageRenderer = ( - stage: Konva.Stage, - container: HTMLDivElement | null, - wrapper: HTMLDivElement | null, - asPreview: boolean -) => { +const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => { const dispatch = useAppDispatch(); const state = useAppSelector((s) => s.controlLayers.present); const tool = useStore($tool); @@ -103,6 +100,10 @@ const useStageRenderer = ( () => clamp(state.brushSize / BRUSH_SPACING_PCT, MIN_BRUSH_SPACING_PX, MAX_BRUSH_SPACING_PX), [state.brushSize] ); + const bbox = useMemo( + () => ({ x: state.x, y: state.y, width: state.size.width, height: state.size.height }), + [state.x, state.y, state.size.width, state.size.height] + ); useLayoutEffect(() => { $brushColor.set(brushColor); @@ -110,6 +111,7 @@ const useStageRenderer = ( $brushSpacingPx.set(brushSpacingPx); $selectedLayer.set(selectedLayer); $shouldInvertBrushSizeScrollDirection.set(shouldInvertBrushSizeScrollDirection); + $genBbox.set(bbox); }, [ brushSpacingPx, brushColor, @@ -118,6 +120,7 @@ const useStageRenderer = ( state.brushSize, state.selectedLayerId, state.brushColor, + bbox, ]); const onLayerPosChanged = useCallback( @@ -164,6 +167,12 @@ const useStageRenderer = ( }, [dispatch] ); + const onBboxTransformed = useCallback( + (bbox: IRect) => { + dispatch(bboxChanged(bbox)); + }, + [dispatch] + ); useLayoutEffect(() => { log.trace('Initializing stage'); @@ -224,28 +233,23 @@ const useStageRenderer = ( useLayoutEffect(() => { log.trace('Updating stage dimensions'); - if (!wrapper) { + if (!container) { return; } const fitStageToContainer = () => { - const newXScale = wrapper.offsetWidth / state.size.width; - const newYScale = wrapper.offsetHeight / state.size.height; - const newScale = Math.min(newXScale, newYScale, 1); - stage.width(state.size.width * newScale); - stage.height(state.size.height * newScale); - stage.scaleX(newScale); - stage.scaleY(newScale); + stage.width(container.offsetWidth); + stage.height(container.offsetHeight); }; const resizeObserver = new ResizeObserver(fitStageToContainer); - resizeObserver.observe(wrapper); + resizeObserver.observe(container); fitStageToContainer(); return () => { resizeObserver.disconnect(); }; - }, [stage, state.size.width, state.size.height, wrapper]); + }, [stage, container]); useLayoutEffect(() => { if (asPreview) { @@ -253,7 +257,7 @@ const useStageRenderer = ( return; } log.trace('Rendering tool preview'); - renderers.renderToolPreview( + renderers.renderPreviewLayer( stage, tool, brushColor, @@ -263,8 +267,11 @@ const useStageRenderer = ( lastMouseDownPos, state.brushSize, isDrawing, - isMouseDown + isMouseDown, + $genBbox, + onBboxTransformed ); + renderImageDimsPreview(stage, bbox, tool); }, [ asPreview, stage, @@ -278,46 +285,23 @@ const useStageRenderer = ( renderers, isDrawing, isMouseDown, - ]); - - useLayoutEffect(() => { - if (asPreview) { - // Preview should not display tool - return; - } - log.trace('Rendering tool preview'); - renderImageDimsPreview(stage, state.size.width, state.size.height, stageScale); - }, [ - asPreview, - stage, - tool, - brushColor, - selectedLayer, - state.globalMaskLayerOpacity, - lastCursorPos, - lastMouseDownPos, - state.brushSize, - renderers, - isDrawing, - isMouseDown, - state.size.width, - state.size.height, + bbox, stageScale, + onBboxTransformed, ]); useLayoutEffect(() => { log.trace('Rendering layers'); - renderers.renderLayers(stage, state.layers, state.globalMaskLayerOpacity, tool, getImageDTO, onLayerPosChanged); - }, [ - stage, - state.layers, - state.globalMaskLayerOpacity, - tool, - onLayerPosChanged, - renderers, - state.size.width, - state.size.height, - ]); + renderers.renderLayers( + stage, + bbox, + state.layers, + state.globalMaskLayerOpacity, + tool, + getImageDTO, + onLayerPosChanged + ); + }, [stage, state.layers, state.globalMaskLayerOpacity, tool, onLayerPosChanged, renderers, bbox]); useLayoutEffect(() => { if (asPreview) { @@ -349,45 +333,42 @@ export const StageComponent = memo(({ asPreview = false }: Props) => { }) ); const [container, setContainer] = useState(null); - const [wrapper, setWrapper] = useState(null); const containerRef = useCallback((el: HTMLDivElement | null) => { setContainer(el); }, []); - const wrapperRef = useCallback((el: HTMLDivElement | null) => { - setWrapper(el); - }, []); - - useStageRenderer(stage, container, wrapper, asPreview); + useStageRenderer(stage, container, asPreview); return ( - - - - {layerCount === 0 && !asPreview && ( - - {t('controlLayers.noLayersAdded')} - - )} + + + {layerCount === 0 && !asPreview && ( + + {t('controlLayers.noLayersAdded')} + + )} - - + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 55aea09bfd..cf95e8902c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -16,7 +16,7 @@ import { clamp } from 'lodash-es'; import type { WritableAtom } from 'nanostores'; import type { RgbaColor } from 'react-colorful'; -import { TOOL_PREVIEW_TOOL_GROUP_ID } from './naming'; +import { PREVIEW_TOOL_GROUP_ID } from './naming'; type SetStageEventHandlersArg = { stage: Konva.Stage; @@ -114,7 +114,7 @@ export const setStageEventHandlers = ({ return; } const tool = $tool.get(); - stage.findOne(`#${TOOL_PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); + stage.findOne(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); }); //#region mousedown @@ -219,7 +219,7 @@ export const setStageEventHandlers = ({ const pos = updateLastCursorPos(stage, $lastCursorPos); const selectedLayer = $selectedLayer.get(); - stage.findOne(`#${TOOL_PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); + stage.findOne(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); if (!pos || !selectedLayer) { return; @@ -277,7 +277,7 @@ export const setStageEventHandlers = ({ const selectedLayer = $selectedLayer.get(); const tool = $tool.get(); - stage.findOne(`#${TOOL_PREVIEW_TOOL_GROUP_ID}`)?.visible(false); + stage.findOne(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(false); if (!pos || !selectedLayer) { return; @@ -338,6 +338,12 @@ export const setStageEventHandlers = ({ } }); + 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())); + }); + const onKeyDown = (e: KeyboardEvent) => { if (e.repeat) { return; @@ -367,7 +373,7 @@ export const setStageEventHandlers = ({ window.addEventListener('keyup', onKeyUp); return () => { - stage.off('mousedown mouseup mousemove mouseenter mouseleave wheel'); + stage.off('mousedown mouseup mousemove mouseenter mouseleave wheel dragend'); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts index 2f61c6cd22..f348393767 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts @@ -3,15 +3,16 @@ */ // IDs for singleton Konva layers and objects -export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer'; -export const TOOL_PREVIEW_TOOL_GROUP_ID = 'tool_preview_layer.tool_group'; -export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group'; -export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill'; -export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner'; -export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer'; -export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect'; -export const TOOL_PREVIEW_IMAGE_DIMS_RECT = 'tool_preview_layer.image_dims_rect'; - +export const PREVIEW_LAYER_ID = 'preview_layer'; +export const PREVIEW_TOOL_GROUP_ID = 'preview_layer.tool_group'; +export const PREVIEW_BRUSH_GROUP_ID = 'preview_layer.brush_group'; +export const PREVIEW_BRUSH_FILL_ID = 'preview_layer.brush_fill'; +export const PREVIEW_BRUSH_BORDER_INNER_ID = 'preview_layer.brush_border_inner'; +export const PREVIEW_BRUSH_BORDER_OUTER_ID = 'preview_layer.brush_border_outer'; +export const PREVIEW_RECT_ID = 'preview_layer.rect'; +export const PREVIEW_GENERATION_BBOX_GROUP = 'preview_layer.gen_bbox_group'; +export const PREVIEW_GENERATION_BBOX_TRANSFORMER = 'preview_layer.gen_bbox_transformer'; +export const PREVIEW_GENERATION_BBOX_DUMMY_RECT = 'preview_layer.gen_bbox_dummy_rect'; // Names for Konva layers and objects (comparable to CSS classes) export const LAYER_BBOX_NAME = 'layer.bbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts index d08d0bd60e..dfc8a15f7c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/caLayer.ts @@ -2,6 +2,7 @@ import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters'; import { CA_LAYER_IMAGE_NAME, CA_LAYER_NAME, getCALayerImageId } from 'features/controlLayers/konva/naming'; import type { ControlAdapterLayer } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import type { IRect } from 'konva/lib/types'; import type { ImageDTO } from 'services/api/types'; /** @@ -18,7 +19,7 @@ const createCALayer = (stage: Konva.Stage, layerState: ControlAdapterLayer): Kon const konvaLayer = new Konva.Layer({ id: layerState.id, name: CA_LAYER_NAME, - imageSmoothingEnabled: true, + imageSmoothingEnabled: false, listening: false, }); stage.add(konvaLayer); @@ -51,6 +52,7 @@ const updateCALayerImageSource = async ( stage: Konva.Stage, konvaLayer: Konva.Layer, layerState: ControlAdapterLayer, + bbox: IRect, getImageDTO: (imageName: string) => Promise ): Promise => { const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image; @@ -72,7 +74,7 @@ const updateCALayerImageSource = async ( id: imageId, image: imageEl, }); - updateCALayerImageAttrs(stage, konvaImage, layerState); + updateCALayerImageAttrs(stage, konvaImage, layerState, bbox); // Must cache after this to apply the filters konvaImage.cache(); imageEl.id = imageId; @@ -93,18 +95,19 @@ const updateCALayerImageSource = async ( const updateCALayerImageAttrs = ( stage: Konva.Stage, konvaImage: Konva.Image, - layerState: ControlAdapterLayer + layerState: ControlAdapterLayer, + bbox: IRect ): void => { let needsCache = false; // Konva erroneously reports NaN for width and height when the stage is hidden. This causes errors when caching, // but it doesn't seem to break anything. // TODO(psyche): Investigate and report upstream. - const newWidth = stage.width() / stage.scaleX(); - const newHeight = stage.height() / stage.scaleY(); const hasFilter = konvaImage.filters() !== null && konvaImage.filters().length > 0; if ( - konvaImage.width() !== newWidth || - konvaImage.height() !== newHeight || + konvaImage.x() !== bbox.x || + konvaImage.y() !== bbox.y || + konvaImage.width() !== bbox.width || + konvaImage.height() !== bbox.height || konvaImage.visible() !== layerState.isEnabled || hasFilter !== layerState.isFilterEnabled ) { @@ -112,8 +115,7 @@ const updateCALayerImageAttrs = ( opacity: layerState.opacity, scaleX: 1, scaleY: 1, - width: stage.width() / stage.scaleX(), - height: stage.height() / stage.scaleY(), + ...bbox, visible: layerState.isEnabled, filters: layerState.isFilterEnabled ? [LightnessToAlphaFilter] : [], }); @@ -137,12 +139,19 @@ const updateCALayerImageAttrs = ( export const renderCALayer = ( stage: Konva.Stage, layerState: ControlAdapterLayer, + bbox: IRect, + zIndex: number, getImageDTO: (imageName: string) => Promise ): void => { const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createCALayer(stage, layerState); + + konvaLayer.zIndex(zIndex); + const konvaImage = konvaLayer.findOne(`.${CA_LAYER_IMAGE_NAME}`); const canvasImageSource = konvaImage?.image(); + let imageSourceNeedsUpdate = false; + if (canvasImageSource instanceof HTMLImageElement) { const image = layerState.controlAdapter.processedImage ?? layerState.controlAdapter.image; if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) { @@ -155,8 +164,8 @@ export const renderCALayer = ( } if (imageSourceNeedsUpdate) { - updateCALayerImageSource(stage, konvaLayer, layerState, getImageDTO); + updateCALayerImageSource(stage, konvaLayer, layerState, bbox, getImageDTO); } else if (konvaImage) { - updateCALayerImageAttrs(stage, konvaImage, layerState); + updateCALayerImageAttrs(stage, konvaImage, layerState, bbox); } }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts index cf1b69d666..a638f69f39 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/iiLayer.ts @@ -124,12 +124,18 @@ const updateIILayerImageSource = async ( export const renderIILayer = ( stage: Konva.Stage, layerState: InitialImageLayer, + zIndex: number, getImageDTO: (imageName: string) => Promise ): void => { const konvaLayer = stage.findOne(`#${layerState.id}`) ?? createIILayer(stage, layerState); + + konvaLayer.zIndex(zIndex); + const konvaImage = konvaLayer.findOne(`.${INITIAL_IMAGE_LAYER_IMAGE_NAME}`); const canvasImageSource = konvaImage?.image(); + let imageSourceNeedsUpdate = false; + if (canvasImageSource instanceof HTMLImageElement) { const image = layerState.image; if (image && canvasImageSource.id !== getCALayerImageId(layerState.id, image.name)) { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts index d50bfae6b2..a2e7f4735c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts @@ -1,11 +1,11 @@ import { DEBOUNCE_MS } from 'features/controlLayers/konva/constants'; -import { TOOL_PREVIEW_LAYER_ID } from 'features/controlLayers/konva/naming'; +import { PREVIEW_LAYER_ID } from 'features/controlLayers/konva/naming'; import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox'; import { renderCALayer } from 'features/controlLayers/konva/renderers/caLayer'; import { renderIILayer } from 'features/controlLayers/konva/renderers/iiLayer'; +import { renderPreviewLayer } from 'features/controlLayers/konva/renderers/previewLayer'; import { renderRasterLayer } from 'features/controlLayers/konva/renderers/rasterLayer'; import { renderRGLayer } from 'features/controlLayers/konva/renderers/rgLayer'; -import { renderToolPreview } from 'features/controlLayers/konva/renderers/toolPreview'; import { mapId, selectRenderableLayers } from 'features/controlLayers/konva/util'; import type { Layer, Tool } from 'features/controlLayers/store/types'; import { @@ -16,6 +16,7 @@ import { isRenderableLayer, } from 'features/controlLayers/store/types'; import type Konva from 'konva'; +import type { IRect } from 'konva/lib/types'; import { debounce } from 'lodash-es'; import type { ImageDTO } from 'services/api/types'; @@ -34,6 +35,7 @@ import type { ImageDTO } from 'services/api/types'; */ const renderLayers = ( stage: Konva.Stage, + bbox: IRect, layerStates: Layer[], globalMaskLayerOpacity: number, tool: Tool, @@ -48,33 +50,33 @@ const renderLayers = ( } } // We'll need to ensure the tool preview layer is on top of the rest of the layers - let toolLayerZIndex = 0; + let zIndex = 0; for (const layer of layerStates) { if (isRegionalGuidanceLayer(layer)) { - renderRGLayer(stage, layer, globalMaskLayerOpacity, tool, onLayerPosChanged); + renderRGLayer(stage, layer, globalMaskLayerOpacity, tool, zIndex, onLayerPosChanged); } if (isControlAdapterLayer(layer)) { - renderCALayer(stage, layer, getImageDTO); + renderCALayer(stage, layer, bbox, zIndex, getImageDTO); } if (isInitialImageLayer(layer)) { - renderIILayer(stage, layer, getImageDTO); + renderIILayer(stage, layer, zIndex, getImageDTO); } if (isRasterLayer(layer)) { - renderRasterLayer(stage, layer, tool, onLayerPosChanged); + renderRasterLayer(stage, layer, tool, zIndex, onLayerPosChanged); } // IP Adapter layers are not rendered // Increment the z-index for the tool layer - toolLayerZIndex++; + zIndex++; } // Arrange the tool preview layer - stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.zIndex(toolLayerZIndex); + stage.findOne(`#${PREVIEW_LAYER_ID}`)?.zIndex(zIndex); }; /** * All the renderers for the Konva stage. */ export const renderers = { - renderToolPreview, + renderPreviewLayer, renderLayers, updateBboxes, }; @@ -85,7 +87,7 @@ export const renderers = { * @returns The renderers with debouncing applied */ const getDebouncedRenderers = (ms = DEBOUNCE_MS): typeof renderers => ({ - renderToolPreview: debounce(renderToolPreview, ms), + renderPreviewLayer: debounce(renderPreviewLayer, ms), renderLayers: debounce(renderLayers, ms), updateBboxes: debounce(updateBboxes, ms), }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts index 32628a39d2..d9ea85e9ca 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts @@ -3,7 +3,7 @@ import { getLayerBboxId, getObjectGroupId, LAYER_BBOX_NAME, - TOOL_PREVIEW_IMAGE_DIMS_RECT, + PREVIEW_GENERATION_BBOX_DUMMY_RECT, } from 'features/controlLayers/konva/naming'; import type { BrushLine, EraserLine, ImageObject, Layer, RectShape } from 'features/controlLayers/store/types'; import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types'; @@ -206,7 +206,7 @@ export const createObjectGroup = (konvaLayer: Konva.Layer, name: string): Konva. export const createImageDimsPreview = (konvaLayer: Konva.Layer, width: number, height: number): Konva.Rect => { const imageDimsPreview = new Konva.Rect({ - id: TOOL_PREVIEW_IMAGE_DIMS_RECT, + id: PREVIEW_GENERATION_BBOX_DUMMY_RECT, x: 0, y: 0, width, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/previewLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/previewLayer.ts new file mode 100644 index 0000000000..2b5a0e6f3f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/previewLayer.ts @@ -0,0 +1,301 @@ +import { roundToMultiple } 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 { + PREVIEW_BRUSH_BORDER_INNER_ID, + PREVIEW_BRUSH_BORDER_OUTER_ID, + PREVIEW_BRUSH_FILL_ID, + PREVIEW_BRUSH_GROUP_ID, + PREVIEW_GENERATION_BBOX_DUMMY_RECT, + PREVIEW_GENERATION_BBOX_GROUP, + PREVIEW_GENERATION_BBOX_TRANSFORMER, + PREVIEW_LAYER_ID, + PREVIEW_RECT_ID, + PREVIEW_TOOL_GROUP_ID, +} from 'features/controlLayers/konva/naming'; +import { selectRenderableLayers, snapPosToStage } from 'features/controlLayers/konva/util'; +import type { Layer, RgbaColor, Tool } from 'features/controlLayers/store/types'; +import Konva from 'konva'; +import type { IRect, Vector2d } from 'konva/lib/types'; +import type { WritableAtom } from 'nanostores'; +import { assert } from 'tsafe'; + +/** + * Creates the singleton preview layer and all its objects. + * @param stage The konva stage + */ +const getPreviewLayer = ( + stage: Konva.Stage, + $genBbox: WritableAtom, + onBboxTransformed: (bbox: IRect) => void +): Konva.Layer => { + let previewLayer = stage.findOne(`#${PREVIEW_LAYER_ID}`); + if (previewLayer) { + return previewLayer; + } + // Initialize the preview layer & add to the stage + previewLayer = new Konva.Layer({ id: PREVIEW_LAYER_ID, listening: true }); + stage.add(previewLayer); + + // Create the brush preview group & circles + const brushPreviewGroup = new Konva.Group({ id: PREVIEW_BRUSH_GROUP_ID }); + const brushPreviewFill = new Konva.Circle({ + id: PREVIEW_BRUSH_FILL_ID, + listening: false, + strokeEnabled: false, + }); + brushPreviewGroup.add(brushPreviewFill); + const brushPreviewBorderInner = new Konva.Circle({ + id: PREVIEW_BRUSH_BORDER_INNER_ID, + listening: false, + stroke: BRUSH_BORDER_INNER_COLOR, + strokeWidth: 1, + strokeEnabled: true, + }); + brushPreviewGroup.add(brushPreviewBorderInner); + const brushPreviewBorderOuter = new Konva.Circle({ + id: PREVIEW_BRUSH_BORDER_OUTER_ID, + listening: false, + stroke: BRUSH_BORDER_OUTER_COLOR, + strokeWidth: 1, + strokeEnabled: true, + }); + brushPreviewGroup.add(brushPreviewBorderOuter); + + // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position + const rectPreview = new Konva.Rect({ + id: PREVIEW_RECT_ID, + listening: false, + stroke: BBOX_SELECTED_STROKE, + strokeWidth: 1, + }); + + const toolGroup = new Konva.Group({ id: PREVIEW_TOOL_GROUP_ID }); + + toolGroup.add(rectPreview); + toolGroup.add(brushPreviewGroup); + + // Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully + // transparent rect for this purpose. + const generationBboxGroup = new Konva.Group({ id: PREVIEW_GENERATION_BBOX_GROUP }); + const generationBboxDummyRect = new Konva.Rect({ + id: PREVIEW_GENERATION_BBOX_DUMMY_RECT, + listening: false, + strokeEnabled: false, + draggable: true, + }); + generationBboxDummyRect.on('dragmove', (e) => { + const bbox: IRect = { + x: roundToMultiple(Math.round(generationBboxDummyRect.x()), 64), + y: roundToMultiple(Math.round(generationBboxDummyRect.y()), 64), + width: Math.round(generationBboxDummyRect.width() * generationBboxDummyRect.scaleX()), + height: Math.round(generationBboxDummyRect.height() * generationBboxDummyRect.scaleY()), + }; + generationBboxDummyRect.setAttrs(bbox); + const genBbox = $genBbox.get(); + if ( + genBbox.x !== bbox.x || + genBbox.y !== bbox.y || + genBbox.width !== bbox.width || + genBbox.height !== bbox.height + ) { + onBboxTransformed(bbox); + } + }); + const generationBboxTransformer = new Konva.Transformer({ + id: PREVIEW_GENERATION_BBOX_TRANSFORMER, + borderDash: [5, 5], + borderStroke: 'rgba(212,216,234,1)', + borderEnabled: true, + rotateEnabled: false, + keepRatio: false, + ignoreStroke: true, + listening: false, + flipEnabled: false, + anchorFill: 'rgba(212,216,234,1)', + anchorStroke: 'rgb(42,42,42)', + anchorSize: 12, + anchorCornerRadius: 3, + anchorStyleFunc: (anchor) => { + // Make the x/y resize anchors little bars + if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) { + anchor.height(8); + anchor.offsetY(4); + anchor.width(30); + anchor.offsetX(15); + } + if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) { + anchor.height(30); + anchor.offsetY(15); + anchor.width(8); + anchor.offsetX(4); + } + }, + }); + generationBboxTransformer.on('transform', (e) => { + const bbox: IRect = { + x: Math.round(generationBboxDummyRect.x()), + y: Math.round(generationBboxDummyRect.y()), + width: Math.round(generationBboxDummyRect.width() * generationBboxDummyRect.scaleX()), + height: Math.round(generationBboxDummyRect.height() * generationBboxDummyRect.scaleY()), + }; + generationBboxDummyRect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 }); + onBboxTransformed(bbox); + }); + // The transformer will always be transforming the dummy rect + generationBboxTransformer.nodes([generationBboxDummyRect]); + generationBboxGroup.add(generationBboxDummyRect); + generationBboxGroup.add(generationBboxTransformer); + previewLayer.add(toolGroup); + previewLayer.add(generationBboxGroup); + + return previewLayer; +}; + +const ALL_ANCHORS: string[] = [ + 'top-left', + 'top-center', + 'top-right', + 'middle-right', + 'middle-left', + 'bottom-left', + 'bottom-center', + 'bottom-right', +]; +const NO_ANCHORS: string[] = []; + +export const renderImageDimsPreview = (stage: Konva.Stage, bbox: IRect, tool: Tool): void => { + const previewLayer = stage.findOne(`#${PREVIEW_LAYER_ID}`); + const generationBboxGroup = stage.findOne(`#${PREVIEW_GENERATION_BBOX_GROUP}`); + const generationBboxDummyRect = stage.findOne(`#${PREVIEW_GENERATION_BBOX_DUMMY_RECT}`); + const generationBboxTransformer = stage.findOne(`#${PREVIEW_GENERATION_BBOX_TRANSFORMER}`); + assert( + previewLayer && generationBboxGroup && generationBboxDummyRect && generationBboxTransformer, + 'Generation bbox konva objects not found' + ); + generationBboxDummyRect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1, listening: tool === 'move' }); + generationBboxTransformer.setAttrs({ + listening: tool === 'move', + enabledAnchors: tool === 'move' ? ALL_ANCHORS : NO_ANCHORS, + }); +}; + +/** + * Renders the preview layer. + * @param stage The konva stage + * @param tool The selected tool + * @param color The selected layer's color + * @param selectedLayerType The selected layer's type + * @param globalMaskLayerOpacity The global mask layer opacity + * @param cursorPos The cursor position + * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool + * @param brushSize The brush size + */ +export const renderPreviewLayer = ( + stage: Konva.Stage, + tool: Tool, + brushColor: RgbaColor, + selectedLayerType: Layer['type'] | null, + globalMaskLayerOpacity: number, + cursorPos: Vector2d | null, + lastMouseDownPos: Vector2d | null, + brushSize: number, + isDrawing: boolean, + isMouseDown: boolean, + $genBbox: WritableAtom, + onBboxTransformed: (bbox: IRect) => void +): void => { + const layerCount = stage.find(selectRenderableLayers).length; + // Update the stage's pointer style + if (tool === 'view') { + // View gets a hand + stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab'; + } else if (layerCount === 0) { + // We have no layers, so we should not render any tool + stage.container().style.cursor = 'default'; + } else if (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') { + // Non-mask-guidance layers don't have tools + stage.container().style.cursor = 'not-allowed'; + } else if (tool === 'move') { + // Move tool gets a pointer + stage.container().style.cursor = 'default'; + } else if (tool === 'rect') { + // Rect gets a crosshair + stage.container().style.cursor = 'crosshair'; + } else { + // Else we hide the native cursor and use the konva-rendered brush preview + stage.container().style.cursor = 'none'; + } + + stage.draggable(tool === 'view'); + + const previewLayer = getPreviewLayer(stage, $genBbox, onBboxTransformed); + const toolGroup = previewLayer.findOne(`#${PREVIEW_TOOL_GROUP_ID}`); + + assert(toolGroup, 'Tool group not found'); + + if ( + !cursorPos || + layerCount === 0 || + (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') + ) { + // We can bail early if the mouse isn't over the stage or there are no layers + toolGroup.visible(false); + } else { + toolGroup.visible(true); + + const brushPreviewGroup = stage.findOne(`#${PREVIEW_BRUSH_GROUP_ID}`); + assert(brushPreviewGroup, 'Brush preview group not found'); + + const rectPreview = stage.findOne(`#${PREVIEW_RECT_ID}`); + assert(rectPreview, 'Rect preview not found'); + + // No need to render the brush preview if the cursor position or color is missing + if (cursorPos && (tool === 'brush' || tool === 'eraser')) { + // Update the fill circle + const brushPreviewFill = brushPreviewGroup.findOne(`#${PREVIEW_BRUSH_FILL_ID}`); + brushPreviewFill?.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: brushSize / 2, + fill: isDrawing ? '' : rgbaColorToString(brushColor), + globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', + }); + + // Update the inner border of the brush preview + const brushPreviewInner = previewLayer.findOne(`#${PREVIEW_BRUSH_BORDER_INNER_ID}`); + brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); + + // Update the outer border of the brush preview + const brushPreviewOuter = previewLayer.findOne(`#${PREVIEW_BRUSH_BORDER_OUTER_ID}`); + brushPreviewOuter?.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: brushSize / 2 + 1, + }); + + brushPreviewGroup.visible(true); + } else { + brushPreviewGroup.visible(false); + } + + if (cursorPos && lastMouseDownPos && tool === 'rect') { + const snappedPos = snapPosToStage(cursorPos, stage); + const rectPreview = previewLayer.findOne(`#${PREVIEW_RECT_ID}`); + rectPreview?.setAttrs({ + x: Math.min(snappedPos.x, lastMouseDownPos.x), + y: Math.min(snappedPos.y, lastMouseDownPos.y), + width: Math.abs(snappedPos.x - lastMouseDownPos.x), + height: Math.abs(snappedPos.y - lastMouseDownPos.y), + fill: rgbaColorToString(brushColor), + }); + rectPreview?.visible(true); + } else { + rectPreview?.visible(false); + } + } +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts index 81f3d570a7..061c2ed0e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rasterLayer.ts @@ -67,6 +67,7 @@ export const renderRasterLayer = async ( stage: Konva.Stage, layerState: RasterLayer, tool: Tool, + zIndex: number, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ) => { const konvaLayer = @@ -77,6 +78,7 @@ export const renderRasterLayer = async ( listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events x: Math.floor(layerState.x), y: Math.floor(layerState.y), + zIndex, }); const konvaObjectGroup = diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts index d6ece5c393..e6bc7e1212 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/rgLayer.ts @@ -83,6 +83,7 @@ export const renderRGLayer = ( layerState: RegionalGuidanceLayer, globalMaskLayerOpacity: number, tool: Tool, + zIndex: number, onLayerPosChanged?: (layerId: string, x: number, y: number) => void ): void => { const konvaLayer = @@ -93,6 +94,7 @@ export const renderRGLayer = ( listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events x: Math.floor(layerState.x), y: Math.floor(layerState.y), + zIndex, }); // Convert the color to a string, stripping the alpha - the object group will handle opacity. diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts deleted file mode 100644 index 95d41bb2f6..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/toolPreview.ts +++ /dev/null @@ -1,212 +0,0 @@ -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 { - TOOL_PREVIEW_BRUSH_BORDER_INNER_ID, - TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID, - TOOL_PREVIEW_BRUSH_FILL_ID, - TOOL_PREVIEW_BRUSH_GROUP_ID, - TOOL_PREVIEW_IMAGE_DIMS_RECT, - TOOL_PREVIEW_LAYER_ID, - TOOL_PREVIEW_RECT_ID, - TOOL_PREVIEW_TOOL_GROUP_ID, -} from 'features/controlLayers/konva/naming'; -import { selectRenderableLayers, snapPosToStage } from 'features/controlLayers/konva/util'; -import type { Layer, RgbaColor, Tool } from 'features/controlLayers/store/types'; -import Konva from 'konva'; -import type { Vector2d } from 'konva/lib/types'; -import { assert } from 'tsafe'; - -/** - * Logic to create and render the singleton tool preview layer. - */ - -/** - * Creates the singleton tool preview layer and all its objects. - * @param stage The konva stage - */ -const getToolPreviewLayer = (stage: Konva.Stage): Konva.Layer => { - let toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`); - if (toolPreviewLayer) { - return toolPreviewLayer; - } - // Initialize the brush preview layer & add to the stage - toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, listening: false }); - stage.add(toolPreviewLayer); - - // Create the brush preview group & circles - const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID }); - const brushPreviewFill = new Konva.Circle({ - id: TOOL_PREVIEW_BRUSH_FILL_ID, - listening: false, - strokeEnabled: false, - }); - brushPreviewGroup.add(brushPreviewFill); - const brushPreviewBorderInner = new Konva.Circle({ - id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID, - listening: false, - stroke: BRUSH_BORDER_INNER_COLOR, - strokeWidth: 1, - strokeEnabled: true, - }); - brushPreviewGroup.add(brushPreviewBorderInner); - const brushPreviewBorderOuter = new Konva.Circle({ - id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID, - listening: false, - stroke: BRUSH_BORDER_OUTER_COLOR, - strokeWidth: 1, - strokeEnabled: true, - }); - brushPreviewGroup.add(brushPreviewBorderOuter); - - // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position - const rectPreview = new Konva.Rect({ - id: TOOL_PREVIEW_RECT_ID, - listening: false, - stroke: BBOX_SELECTED_STROKE, - strokeWidth: 1, - }); - - const toolGroup = new Konva.Group({ id: TOOL_PREVIEW_TOOL_GROUP_ID }); - - toolGroup.add(rectPreview); - toolGroup.add(brushPreviewGroup); - - const imageDimsPreview = new Konva.Rect({ - id: TOOL_PREVIEW_IMAGE_DIMS_RECT, - x: 0, - y: 0, - width: 0, - height: 0, - stroke: 'rgb(255,0,255)', - strokeWidth: 1 / toolPreviewLayer.getStage().scaleX(), - listening: false, - }); - - toolPreviewLayer.add(toolGroup); - toolPreviewLayer.add(imageDimsPreview); - - return toolPreviewLayer; -}; - -export const renderImageDimsPreview = (stage: Konva.Stage, width: number, height: number, stageScale: number): void => { - const imageDimsPreview = stage.findOne(`#${TOOL_PREVIEW_IMAGE_DIMS_RECT}`); - imageDimsPreview?.setAttrs({ - width, - height, - strokeWidth: 1 / stageScale, - }); -}; - -/** - * Renders the brush preview for the selected tool. - * @param stage The konva stage - * @param tool The selected tool - * @param color The selected layer's color - * @param selectedLayerType The selected layer's type - * @param globalMaskLayerOpacity The global mask layer opacity - * @param cursorPos The cursor position - * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool - * @param brushSize The brush size - */ -export const renderToolPreview = ( - stage: Konva.Stage, - tool: Tool, - brushColor: RgbaColor, - selectedLayerType: Layer['type'] | null, - globalMaskLayerOpacity: number, - cursorPos: Vector2d | null, - lastMouseDownPos: Vector2d | null, - brushSize: number, - isDrawing: boolean, - isMouseDown: boolean -): void => { - const layerCount = stage.find(selectRenderableLayers).length; - // Update the stage's pointer style - if (tool === 'view') { - // View gets a hand - stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab'; - } else if (layerCount === 0) { - // We have no layers, so we should not render any tool - stage.container().style.cursor = 'default'; - } else if (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') { - // Non-mask-guidance layers don't have tools - stage.container().style.cursor = 'not-allowed'; - } else if (tool === 'move') { - // Move tool gets a pointer - stage.container().style.cursor = 'default'; - } else if (tool === 'rect') { - // Rect gets a crosshair - stage.container().style.cursor = 'crosshair'; - } else { - // Else we hide the native cursor and use the konva-rendered brush preview - stage.container().style.cursor = 'none'; - } - - stage.draggable(tool === 'view'); - - const toolPreviewLayer = getToolPreviewLayer(stage); - const toolGroup = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_TOOL_GROUP_ID}`); - - assert(toolGroup, 'Tool group not found'); - - if (!cursorPos || layerCount === 0) { - // We can bail early if the mouse isn't over the stage or there are no layers - toolGroup.visible(false); - } else { - toolGroup.visible(true); - - const brushPreviewGroup = stage.findOne(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`); - assert(brushPreviewGroup, 'Brush preview group not found'); - - const rectPreview = stage.findOne(`#${TOOL_PREVIEW_RECT_ID}`); - assert(rectPreview, 'Rect preview not found'); - - // No need to render the brush preview if the cursor position or color is missing - if (cursorPos && (tool === 'brush' || tool === 'eraser')) { - // Update the fill circle - const brushPreviewFill = brushPreviewGroup.findOne(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`); - brushPreviewFill?.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: brushSize / 2, - fill: isDrawing ? '' : rgbaColorToString(brushColor), - globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', - }); - - // Update the inner border of the brush preview - const brushPreviewInner = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_INNER_ID}`); - brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); - - // Update the outer border of the brush preview - const brushPreviewOuter = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID}`); - brushPreviewOuter?.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: brushSize / 2 + 1, - }); - - brushPreviewGroup.visible(true); - } else { - brushPreviewGroup.visible(false); - } - - if (cursorPos && lastMouseDownPos && tool === 'rect') { - const snappedPos = snapPosToStage(cursorPos, stage); - const rectPreview = toolPreviewLayer.findOne(`#${TOOL_PREVIEW_RECT_ID}`); - rectPreview?.setAttrs({ - x: Math.min(snappedPos.x, lastMouseDownPos.x), - y: Math.min(snappedPos.y, lastMouseDownPos.y), - width: Math.abs(snappedPos.x - lastMouseDownPos.x), - height: Math.abs(snappedPos.y - lastMouseDownPos.y), - fill: rgbaColorToString(brushColor), - }); - rectPreview?.visible(true); - } else { - rectPreview?.visible(false); - } - } -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index 53141e4ff7..7d590ba4cd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -92,6 +92,8 @@ export const initialControlLayersState: ControlLayersState = { height: 512, aspectRatio: deepClone(initialAspectRatioState), }, + x: 0, + y: 0, }; /** @@ -797,6 +799,12 @@ export const controlLayersSlice = createSlice({ aspectRatioChanged: (state, action: PayloadAction) => { state.size.aspectRatio = action.payload; }, + bboxChanged: (state, action: PayloadAction) => { + state.x = action.payload.x; + state.y = action.payload.y; + state.size.width = action.payload.width; + state.size.height = action.payload.height; + }, brushSizeChanged: (state, action: PayloadAction) => { state.brushSize = Math.round(action.payload); }, @@ -950,6 +958,7 @@ export const { widthChanged, heightChanged, aspectRatioChanged, + bboxChanged, brushSizeChanged, brushColorChanged, globalMaskLayerOpacityChanged, @@ -989,6 +998,7 @@ export const $lastAddedPoint = atom(null); export const $isSpaceDown = atom(false); export const $stageScale = atom(1); export const $stagePos = atom({ x: 0, y: 0 }); +export const $genBbox = atom({ x: 0, y: 0, width: 0, height: 0 }); // Some nanostores that are manually synced to redux state to provide imperative access // TODO(psyche): This is a hack, figure out another way to handle this... diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 377d632400..d229eb1e45 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -269,6 +269,8 @@ export type ControlLayersState = { height: ParameterHeight; aspectRatio: AspectRatioState; }; + x: number; + y: number; }; export type AddEraserLineArg = { layerId: string; points: [number, number, number, number] };