diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBbox.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBbox.ts index d60896ae9d..6f7ff99593 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBbox.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBbox.ts @@ -90,7 +90,7 @@ export class CanvasBbox { assert(stage, 'Stage must exist'); // We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid. - const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64; + const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64; // Because we are working in absolute coordinates, we need to scale the grid size by the stage scale. const scaledGridSize = gridSize * stage.scaleX(); // To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position. @@ -107,7 +107,7 @@ export class CanvasBbox { }), }; this.konva.rect.on('dragmove', () => { - const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64; + const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64; const bbox = this.manager.stateApi.getBbox(); const bboxRect: Rect = { ...bbox.rect, @@ -129,10 +129,10 @@ export class CanvasBbox { return; } - const alt = this.manager.stateApi.getAltKey(); - const ctrl = this.manager.stateApi.getCtrlKey(); - const meta = this.manager.stateApi.getMetaKey(); - const shift = this.manager.stateApi.getShiftKey(); + const alt = this.manager.stateApi.$altKey.get(); + const ctrl = this.manager.stateApi.$ctrlKey.get(); + const meta = this.manager.stateApi.$metaKey.get(); + const shift = this.manager.stateApi.$shiftKey.get(); // Grid size depends on the modifier keys let gridSize = ctrl || meta ? 8 : 64; @@ -141,7 +141,7 @@ export class CanvasBbox { // new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if // we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes. // Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid. - if (this.manager.stateApi.getAltKey()) { + if (this.manager.stateApi.$altKey.get()) { gridSize = gridSize * 2; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts index 679dad49df..a03e1eb9e1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts @@ -11,8 +11,9 @@ export class CanvasControlAdapter extends CanvasEntity { static TRANSFORMER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_transformer`; static GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_group`; static OBJECT_GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_object-group`; + static TYPE = 'control_adapter' as const; - type = 'control_adapter'; + type = CanvasControlAdapter.TYPE; _state: CanvasControlAdapterState; konva: { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInpaintMask.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInpaintMask.ts index 681ded2bb9..5d354ca169 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInpaintMask.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInpaintMask.ts @@ -5,7 +5,12 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect'; import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox'; import { mapId } from 'features/controlLayers/konva/util'; -import type { CanvasBrushLineState, CanvasEraserLineState, CanvasInpaintMaskState, CanvasRectState } from 'features/controlLayers/store/types'; +import type { + CanvasBrushLineState, + CanvasEraserLineState, + CanvasInpaintMaskState, + CanvasRectState, +} from 'features/controlLayers/store/types'; import { isDrawingTool, RGBA_RED } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { assert } from 'tsafe'; @@ -17,11 +22,12 @@ export class CanvasInpaintMask { static GROUP_NAME = `${CanvasInpaintMask.NAME_PREFIX}_group`; static OBJECT_GROUP_NAME = `${CanvasInpaintMask.NAME_PREFIX}_object-group`; static COMPOSITING_RECT_NAME = `${CanvasInpaintMask.NAME_PREFIX}_compositing-rect`; - + static TYPE = 'inpaint_mask' as const; private drawingBuffer: CanvasBrushLineState | CanvasEraserLineState | CanvasRectState | null; private state: CanvasInpaintMaskState; - id = 'inpaint_mask'; + id = CanvasInpaintMask.TYPE; + type = CanvasInpaintMask.TYPE; manager: CanvasManager; konva: { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts index 14acffcf09..c865aca7c3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts @@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone'; import { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer'; import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; -import { konvaNodeToBlob, previewBlob } from 'features/controlLayers/konva/util'; +import { getEmptyRect, konvaNodeToBlob, previewBlob } from 'features/controlLayers/konva/util'; import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice'; import type { CanvasLayerState, @@ -19,11 +19,12 @@ import type { Logger } from 'roarr'; import { uploadImage } from 'services/api/endpoints/images'; export class CanvasLayer { - static TYPE = 'layer'; + static TYPE = 'layer' as const; static KONVA_LAYER_NAME = `${CanvasLayer.TYPE}_layer`; static KONVA_OBJECT_GROUP_NAME = `${CanvasLayer.TYPE}_object-group`; id: string; + type = CanvasLayer.TYPE; manager: CanvasManager; log: Logger; getLoggingContext: GetLoggingContext; @@ -38,12 +39,11 @@ export class CanvasLayer { renderer: CanvasObjectRenderer; isFirstRender: boolean = true; - bboxNeedsUpdate: boolean; - isTransforming: boolean; - isPendingBboxCalculation: boolean; + bboxNeedsUpdate: boolean = true; + isPendingBboxCalculation: boolean = false; - rect: Rect; - bbox: Rect; + rect: Rect = getEmptyRect(); + bbox: Rect = getEmptyRect(); constructor(state: CanvasLayerState, manager: CanvasManager) { this.id = state.id; @@ -69,11 +69,6 @@ export class CanvasLayer { this.konva.layer.add(...this.transformer.getNodes()); this.state = state; - this.rect = this.getDefaultRect(); - this.bbox = this.getDefaultRect(); - this.bboxNeedsUpdate = true; - this.isTransforming = false; - this.isPendingBboxCalculation = false; } destroy = (): void => { @@ -86,8 +81,6 @@ export class CanvasLayer { update = async (arg?: { state: CanvasLayerState; toolState: CanvasV2State['tool']; isSelected: boolean }) => { const state = get(arg, 'state', this.state); - const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState()); - const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id)); if (!this.isFirstRender && state === this.state) { this.log.trace('State unchanged, skipping update'); @@ -109,7 +102,7 @@ export class CanvasLayer { if (this.isFirstRender || isEnabled !== this.state.isEnabled) { await this.updateVisibility({ isEnabled }); } - await this.updateInteraction({ toolState, isSelected }); + // this.transformer.syncInteractionState(); if (this.isFirstRender) { await this.updateBbox(); @@ -159,40 +152,6 @@ export class CanvasLayer { this.konva.objectGroup.opacity(opacity); }; - updateInteraction = (arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) => { - this.log.trace('Updating interaction'); - - const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState()); - const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id)); - - if (!this.renderer.hasObjects()) { - // The layer is totally empty, we can just disable the layer - this.konva.layer.listening(false); - this.transformer.setMode('off'); - return; - } - - if (isSelected && !this.isTransforming && toolState.selected === 'move') { - // We are moving this layer, it must be listening - this.konva.layer.listening(true); - this.transformer.setMode('drag'); - } else if (isSelected && this.isTransforming) { - // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is - // active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected. - if (toolState.selected !== 'view') { - this.konva.layer.listening(true); - this.transformer.setMode('transform'); - } else { - this.konva.layer.listening(false); - this.transformer.setMode('off'); - } - } else { - // The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff - this.konva.layer.listening(false); - this.transformer.setMode('off'); - } - }; - updateBbox = () => { this.log.trace('Updating bbox'); @@ -208,11 +167,11 @@ export class CanvasLayer { // The layer is fully transparent but has objects - reset it this.manager.stateApi.onEntityReset({ id: this.id }, 'layer'); } - this.transformer.setMode('off'); + this.transformer.syncInteractionState(); return; } - this.transformer.setMode('drag'); + this.transformer.syncInteractionState(); this.transformer.update(this.state.position, this.bbox); this.konva.objectGroup.setAttrs({ x: this.state.position.x + this.bbox.x, @@ -222,18 +181,6 @@ export class CanvasLayer { }); }; - startTransform = () => { - this.log.debug('Starting transform'); - this.isTransforming = true; - - // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or - // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening - // when the view tool is selected - const shouldListen = this.manager.stateApi.getToolState().selected !== 'view'; - this.konva.layer.listening(shouldListen); - this.transformer.setMode('transform'); - }; - resetScale = () => { const attrs = { scaleX: 1, @@ -245,7 +192,7 @@ export class CanvasLayer { this.transformer.konva.proxyRect.setAttrs(attrs); }; - rasterizeLayer = async () => { + rasterize = async () => { this.log.debug('Rasterizing layer'); const objectGroupClone = this.konva.objectGroup.clone(); @@ -263,20 +210,6 @@ export class CanvasLayer { dispatch(layerRasterized({ id: this.id, imageObject, position: { x: Math.round(rect.x), y: Math.round(rect.y) } })); }; - stopTransform = () => { - this.log.debug('Stopping transform'); - - this.isTransforming = false; - this.resetScale(); - this.updatePosition(); - this.updateBbox(); - this.updateInteraction(); - }; - - getDefaultRect = (): Rect => { - return { x: 0, y: 0, width: 0, height: 0 }; - }; - calculateBbox = debounce(() => { this.log.debug('Calculating bbox'); @@ -284,8 +217,8 @@ export class CanvasLayer { if (!this.renderer.hasObjects()) { this.log.trace('No objects, resetting bbox'); - this.rect = this.getDefaultRect(); - this.bbox = this.getDefaultRect(); + this.rect = getEmptyRect(); + this.bbox = getEmptyRect(); this.isPendingBboxCalculation = false; this.updateBbox(); return; @@ -324,8 +257,8 @@ export class CanvasLayer { height: maxY - minY, }; } else { - this.bbox = this.getDefaultRect(); - this.rect = this.getDefaultRect(); + this.bbox = getEmptyRect(); + this.rect = getEmptyRect(); } this.isPendingBboxCalculation = false; this.log.trace({ bbox: this.bbox, rect: this.rect, extents }, `Got bbox from worker`); @@ -343,7 +276,6 @@ export class CanvasLayer { rect: deepClone(this.rect), bbox: deepClone(this.bbox), bboxNeedsUpdate: this.bboxNeedsUpdate, - isTransforming: this.isTransforming, isPendingBboxCalculation: this.isPendingBboxCalculation, transformer: this.transformer.repr(), renderer: this.renderer.repr(), diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index ffb1678376..0f93253454 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -2,6 +2,7 @@ import type { Store } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { JSONObject } from 'common/types'; +import { PubSub } from 'common/util/PubSub/PubSub'; import type { CanvasBrushLineRenderer } from 'features/controlLayers/konva/CanvasBrushLine'; import type { CanvasEraserLineRenderer } from 'features/controlLayers/konva/CanvasEraserLine'; import type { CanvasImageRenderer } from 'features/controlLayers/konva/CanvasImage'; @@ -22,7 +23,19 @@ import { } from 'features/controlLayers/konva/util'; import type { Extents, ExtentsResult, GetBboxTask, WorkerLogMessage } from 'features/controlLayers/konva/worker'; import { $lastProgressEvent, $shouldShowStagedImage } from 'features/controlLayers/store/canvasV2Slice'; -import type { CanvasV2State, Coordinate, GenerationMode, GetLoggingContext } from 'features/controlLayers/store/types'; +import type { + CanvasControlAdapterState, + CanvasEntity, + CanvasEntityIdentifier, + CanvasInpaintMaskState, + CanvasLayerState, + CanvasRegionalGuidanceState, + CanvasV2State, + Coordinate, + GenerationMode, + GetLoggingContext, + RgbaColor, +} from 'features/controlLayers/store/types'; import type Konva from 'konva'; import { atom } from 'nanostores'; import type { Logger } from 'roarr'; @@ -70,6 +83,24 @@ type Util = { ) => Promise; }; +type EntityStateAndAdapter = + | { + state: CanvasLayerState; + adapter: CanvasLayer; + } + | { + state: CanvasInpaintMaskState; + adapter: CanvasInpaintMask; + } + | { + state: CanvasControlAdapterState; + adapter: CanvasControlAdapter; + } + | { + state: CanvasRegionalGuidanceState; + adapter: CanvasRegion; + }; + export const $canvasManager = atom(null); export class CanvasManager { @@ -101,6 +132,11 @@ export class CanvasManager { _worker: Worker; _tasks: Map void }>; + toolState: PubSub; + currentFill: PubSub; + selectedEntity: PubSub; + selectedEntityIdentifier: PubSub; + constructor( stage: Konva.Stage, container: HTMLDivElement, @@ -111,7 +147,7 @@ export class CanvasManager { this.stage = stage; this.container = container; this._store = store; - this.stateApi = new CanvasStateApi(this._store); + this.stateApi = new CanvasStateApi(this._store, this); this._prevState = this.stateApi.getState(); this._isFirstRender = true; @@ -178,6 +214,17 @@ export class CanvasManager { }; this.onTransform = null; this._isDebugging = false; + + this.toolState = new PubSub(this.stateApi.getToolState()); + this.currentFill = new PubSub(this.getCurrentFill()); + this.selectedEntityIdentifier = new PubSub( + this.stateApi.getState().selectedEntityIdentifier, + (a, b) => a?.id === b?.id + ); + this.selectedEntity = new PubSub( + this.getSelectedEntity(), + (a, b) => a?.state === b?.state && a?.adapter === b?.adapter + ); } enableDebugging() { @@ -226,7 +273,7 @@ export class CanvasManager { } async renderProgressPreview() { - await this.preview.progressPreview.render(this.stateApi.getLastProgressEvent()); + await this.preview.progressPreview.render(this.stateApi.$lastProgressEvent.get()); } async renderInpaintMask() { @@ -279,7 +326,7 @@ export class CanvasManager { fitStageToContainer() { this.stage.width(this.container.offsetWidth); this.stage.height(this.container.offsetHeight); - this.stateApi.setStageAttrs({ + this.stateApi.$stageAttrs.set({ position: { x: this.stage.x(), y: this.stage.y() }, dimensions: { width: this.stage.width(), height: this.stage.height() }, scale: this.stage.scaleX(), @@ -287,8 +334,57 @@ export class CanvasManager { this.background.render(); } + getEntity(identifier: CanvasEntityIdentifier): EntityStateAndAdapter | null { + const state = this.stateApi.getState(); + + let entityState: CanvasEntity | null = null; + let entityAdapter: CanvasLayer | CanvasRegion | CanvasControlAdapter | CanvasInpaintMask | null = null; + + if (identifier.type === 'layer') { + entityState = state.layers.entities.find((i) => i.id === identifier.id) ?? null; + entityAdapter = this.layers.get(identifier.id) ?? null; + } else if (identifier.type === 'control_adapter') { + entityState = state.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null; + entityAdapter = this.controlAdapters.get(identifier.id) ?? null; + } else if (identifier.type === 'regional_guidance') { + entityState = state.regions.entities.find((i) => i.id === identifier.id) ?? null; + entityAdapter = this.regions.get(identifier.id) ?? null; + } else if (identifier.type === 'inpaint_mask') { + entityState = state.inpaintMask; + entityAdapter = this.inpaintMask; + } + + if (entityState && entityAdapter && entityState.type === entityAdapter.type) { + return { state: entityState, adapter: entityAdapter } as EntityStateAndAdapter; + } + + return null; + } + + getSelectedEntity = () => { + const state = this.stateApi.getState(); + if (state.selectedEntityIdentifier) { + return this.getEntity(state.selectedEntityIdentifier); + } + return null; + }; + + getCurrentFill = () => { + const state = this.stateApi.getState(); + let currentFill: RgbaColor = state.tool.fill; + const selectedEntity = this.getSelectedEntity(); + if (selectedEntity) { + if (selectedEntity.state.type === 'regional_guidance') { + currentFill = { ...selectedEntity.state.fill, a: state.settings.maskOpacity }; + } else if (selectedEntity.state.type === 'inpaint_mask') { + currentFill = { ...state.inpaintMask.fill, a: state.settings.maskOpacity }; + } + } + return currentFill; + }; + getTransformingLayer() { - return Array.from(this.layers.values()).find((layer) => layer.isTransforming); + return Array.from(this.layers.values()).find((layer) => layer.transformer.isTransforming); } getIsTransforming() { @@ -299,17 +395,17 @@ export class CanvasManager { if (this.getIsTransforming()) { return; } - const layer = this.getSelectedEntityAdapter(); - assert(layer instanceof CanvasLayer, 'No selected layer'); - layer.startTransform(); + const layer = this.getSelectedEntity(); + // TODO(psyche): Support other entity types + assert(layer?.adapter instanceof CanvasLayer, 'No selected layer'); + layer.adapter.transformer.startTransform(); this.onTransform?.(true); } async applyTransform() { const layer = this.getTransformingLayer(); if (layer) { - await layer.rasterizeLayer(); - layer.stopTransform(); + await layer.transformer.applyTransform(); } this.onTransform?.(false); } @@ -317,7 +413,7 @@ export class CanvasManager { cancelTransform() { const layer = this.getTransformingLayer(); if (layer) { - layer.stopTransform(); + layer.transformer.stopTransform(); } this.onTransform?.(false); } @@ -355,16 +451,10 @@ export class CanvasManager { } } - if ( - this._isFirstRender || - state.tool.selected !== this._prevState.tool.selected || - state.selectedEntityIdentifier?.id !== this._prevState.selectedEntityIdentifier?.id - ) { - this.log.debug('Updating interaction'); - for (const layer of this.layers.values()) { - layer.updateInteraction({ toolState: state.tool, isSelected: state.selectedEntityIdentifier?.id === layer.id }); - } - } + this.toolState.publish(state.tool); + this.selectedEntityIdentifier.publish(state.selectedEntityIdentifier); + this.selectedEntity.publish(this.getSelectedEntity()); + this.currentFill.publish(this.getCurrentFill()); if ( this._isFirstRender || @@ -521,24 +611,6 @@ export class CanvasManager { return CanvasManager.BBOX_PADDING_PX; } - getSelectedEntityAdapter = (): CanvasLayer | CanvasRegion | CanvasControlAdapter | CanvasInpaintMask | null => { - const state = this.stateApi.getState(); - const identifier = state.selectedEntityIdentifier; - if (!identifier) { - return null; - } else if (identifier.type === 'layer') { - return this.layers.get(identifier.id) ?? null; - } else if (identifier.type === 'control_adapter') { - return this.controlAdapters.get(identifier.id) ?? null; - } else if (identifier.type === 'regional_guidance') { - return this.regions.get(identifier.id) ?? null; - } else if (identifier.type === 'inpaint_mask') { - return this.inpaintMask; - } else { - return null; - } - }; - getGenerationMode(): GenerationMode { const session = this.stateApi.getSession(); if (session.isActive) { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts index ab2ae03844..303864e025 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts @@ -25,6 +25,9 @@ type AnyObjectRenderer = CanvasBrushLineRenderer | CanvasEraserLineRenderer | Ca */ type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImageState | CanvasRectState; +/** + * Handles rendering of objects for a canvas entity. + */ export class CanvasObjectRenderer { static TYPE = 'object_renderer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRegion.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRegion.ts index c48f95d851..833314ae02 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRegion.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRegion.ts @@ -5,7 +5,12 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect'; import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox'; import { mapId } from 'features/controlLayers/konva/util'; -import type { CanvasBrushLineState, CanvasEraserLineState, CanvasRectState, CanvasRegionalGuidanceState } from 'features/controlLayers/store/types'; +import type { + CanvasBrushLineState, + CanvasEraserLineState, + CanvasRectState, + CanvasRegionalGuidanceState, +} from 'features/controlLayers/store/types'; import { isDrawingTool, RGBA_RED } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { assert } from 'tsafe'; @@ -17,11 +22,13 @@ export class CanvasRegion { static GROUP_NAME = `${CanvasRegion.NAME_PREFIX}_group`; static OBJECT_GROUP_NAME = `${CanvasRegion.NAME_PREFIX}_object-group`; static COMPOSITING_RECT_NAME = `${CanvasRegion.NAME_PREFIX}_compositing-rect`; + static TYPE = 'regional_guidance' as const; private drawingBuffer: CanvasBrushLineState | CanvasEraserLineState | CanvasRectState | null; private state: CanvasRegionalGuidanceState; id: string; + type = CanvasRegion.TYPE; manager: CanvasManager; konva: { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts index 64e763aa6c..47b679ed87 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts @@ -34,7 +34,7 @@ export class CanvasStagingArea { render = async () => { const session = this.manager.stateApi.getSession(); const bboxRect = this.manager.stateApi.getBbox().rect; - const shouldShowStagedImage = this.manager.stateApi.getShouldShowStagedImage(); + const shouldShowStagedImage = this.manager.stateApi.$shouldShowStagedImage.get(); this.selectedImage = session.stagedImages[session.selectedStagedImageIndex] ?? null; @@ -69,7 +69,7 @@ export class CanvasStagingArea { this.image.konva.group.x(bboxRect.x + offsetX); this.image.konva.group.y(bboxRect.y + offsetY); await this.image.updateImageSource(imageDTO.image_name); - this.manager.stateApi.resetLastProgressEvent(); + this.manager.stateApi.$lastProgressEvent.set(null); } this.image.konva.group.visible(shouldShowStagedImage); } else { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts index 4983b4c9af..6b4be9ac6d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts @@ -2,7 +2,7 @@ import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library'; import type { Store } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; -import { buildSubscribe } from 'features/controlLayers/konva/util'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { $isDrawing, $isMouseDown, @@ -49,173 +49,143 @@ import type { CanvasBrushLineState, CanvasEntity, CanvasEraserLineState, - PositionChangedArg, CanvasRectState, + PositionChangedArg, ScaleChangedArg, Tool, } from 'features/controlLayers/store/types'; import type { IRect } from 'konva/lib/types'; -import type { RgbaColor } from 'react-colorful'; import type { ImageDTO } from 'services/api/types'; const log = logger('canvas'); -export class CanvasStateApi { - private store: Store; - constructor(store: Store) { - this.store = store; +export class CanvasStateApi { + _store: Store; + manager: CanvasManager; + + + constructor(store: Store, manager: CanvasManager) { + this._store = store; + this.manager = manager; + } // Reminder - use arrow functions to avoid binding issues getState = () => { - return this.store.getState().canvasV2; + return this._store.getState().canvasV2; }; onEntityReset = (arg: { id: string }, entityType: CanvasEntity['type']) => { log.debug('onEntityReset'); if (entityType === 'layer') { - this.store.dispatch(layerReset(arg)); + this._store.dispatch(layerReset(arg)); } }; onPosChanged = (arg: PositionChangedArg, entityType: CanvasEntity['type']) => { log.debug('onPosChanged'); if (entityType === 'layer') { - this.store.dispatch(layerTranslated(arg)); + this._store.dispatch(layerTranslated(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgTranslated(arg)); + this._store.dispatch(rgTranslated(arg)); } else if (entityType === 'inpaint_mask') { - this.store.dispatch(imTranslated(arg)); + this._store.dispatch(imTranslated(arg)); } else if (entityType === 'control_adapter') { - this.store.dispatch(caTranslated(arg)); + this._store.dispatch(caTranslated(arg)); } }; onScaleChanged = (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => { log.debug('onScaleChanged'); if (entityType === 'inpaint_mask') { - this.store.dispatch(imScaled(arg)); + this._store.dispatch(imScaled(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgScaled(arg)); + this._store.dispatch(rgScaled(arg)); } else if (entityType === 'control_adapter') { - this.store.dispatch(caScaled(arg)); + this._store.dispatch(caScaled(arg)); } }; onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => { log.debug('Entity bbox changed'); if (entityType === 'layer') { - this.store.dispatch(layerBboxChanged(arg)); + this._store.dispatch(layerBboxChanged(arg)); } else if (entityType === 'control_adapter') { - this.store.dispatch(caBboxChanged(arg)); + this._store.dispatch(caBboxChanged(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgBboxChanged(arg)); + this._store.dispatch(rgBboxChanged(arg)); } else if (entityType === 'inpaint_mask') { - this.store.dispatch(imBboxChanged(arg)); + this._store.dispatch(imBboxChanged(arg)); } }; onBrushLineAdded = (arg: { id: string; brushLine: CanvasBrushLineState }, entityType: CanvasEntity['type']) => { log.debug('Brush line added'); if (entityType === 'layer') { - this.store.dispatch(layerBrushLineAdded(arg)); + this._store.dispatch(layerBrushLineAdded(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgBrushLineAdded(arg)); + this._store.dispatch(rgBrushLineAdded(arg)); } else if (entityType === 'inpaint_mask') { - this.store.dispatch(imBrushLineAdded(arg)); + this._store.dispatch(imBrushLineAdded(arg)); } }; onEraserLineAdded = (arg: { id: string; eraserLine: CanvasEraserLineState }, entityType: CanvasEntity['type']) => { log.debug('Eraser line added'); if (entityType === 'layer') { - this.store.dispatch(layerEraserLineAdded(arg)); + this._store.dispatch(layerEraserLineAdded(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgEraserLineAdded(arg)); + this._store.dispatch(rgEraserLineAdded(arg)); } else if (entityType === 'inpaint_mask') { - this.store.dispatch(imEraserLineAdded(arg)); + this._store.dispatch(imEraserLineAdded(arg)); } }; onRectShapeAdded = (arg: { id: string; rectShape: CanvasRectState }, entityType: CanvasEntity['type']) => { log.debug('Rect shape added'); if (entityType === 'layer') { - this.store.dispatch(layerRectShapeAdded(arg)); + this._store.dispatch(layerRectShapeAdded(arg)); } else if (entityType === 'regional_guidance') { - this.store.dispatch(rgRectShapeAdded(arg)); + this._store.dispatch(rgRectShapeAdded(arg)); } else if (entityType === 'inpaint_mask') { - this.store.dispatch(imRectShapeAdded(arg)); + this._store.dispatch(imRectShapeAdded(arg)); } }; onEntitySelected = (arg: { id: string; type: CanvasEntity['type'] }) => { log.debug('Entity selected'); - this.store.dispatch(entitySelected(arg)); + this._store.dispatch(entitySelected(arg)); }; onBboxTransformed = (bbox: IRect) => { log.debug('Generation bbox transformed'); - this.store.dispatch(bboxChanged(bbox)); + this._store.dispatch(bboxChanged(bbox)); }; onBrushWidthChanged = (width: number) => { log.debug('Brush width changed'); - this.store.dispatch(brushWidthChanged(width)); + this._store.dispatch(brushWidthChanged(width)); }; onEraserWidthChanged = (width: number) => { log.debug('Eraser width changed'); - this.store.dispatch(eraserWidthChanged(width)); + this._store.dispatch(eraserWidthChanged(width)); }; onRegionMaskImageCached = (id: string, imageDTO: ImageDTO) => { log.debug('Region mask image cached'); - this.store.dispatch(rgImageCacheChanged({ id, imageDTO })); + this._store.dispatch(rgImageCacheChanged({ id, imageDTO })); }; onInpaintMaskImageCached = (imageDTO: ImageDTO) => { log.debug('Inpaint mask image cached'); - this.store.dispatch(imImageCacheChanged({ imageDTO })); + this._store.dispatch(imImageCacheChanged({ imageDTO })); }; onLayerImageCached = (imageDTO: ImageDTO) => { log.debug('Layer image cached'); - this.store.dispatch(layerImageCacheChanged({ imageDTO })); + this._store.dispatch(layerImageCacheChanged({ imageDTO })); }; setTool = (tool: Tool) => { log.debug('Tool selection changed'); - this.store.dispatch(toolChanged(tool)); + this._store.dispatch(toolChanged(tool)); }; setToolBuffer = (toolBuffer: Tool | null) => { log.debug('Tool buffer changed'); - this.store.dispatch(toolBufferChanged(toolBuffer)); + this._store.dispatch(toolBufferChanged(toolBuffer)); }; - getSelectedEntity = (): CanvasEntity | null => { - const state = this.getState(); - const identifier = state.selectedEntityIdentifier; - if (!identifier) { - return null; - } else if (identifier.type === 'layer') { - return state.layers.entities.find((i) => i.id === identifier.id) ?? null; - } else if (identifier.type === 'control_adapter') { - return state.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null; - } else if (identifier.type === 'ip_adapter') { - return state.ipAdapters.entities.find((i) => i.id === identifier.id) ?? null; - } else if (identifier.type === 'regional_guidance') { - return state.regions.entities.find((i) => i.id === identifier.id) ?? null; - } else if (identifier.type === 'inpaint_mask') { - return state.inpaintMask; - } else { - return null; - } - }; - - getCurrentFill = () => { - const state = this.getState(); - const selectedEntity = this.getSelectedEntity(); - let currentFill: RgbaColor = state.tool.fill; - if (selectedEntity) { - if (selectedEntity.type === 'regional_guidance') { - currentFill = { ...selectedEntity.fill, a: state.settings.maskOpacity }; - } else if (selectedEntity.type === 'inpaint_mask') { - currentFill = { ...state.inpaintMask.fill, a: state.settings.maskOpacity }; - } - } else { - currentFill = state.tool.fill; - } - return currentFill; - }; getBbox = () => { return this.getState().bbox; }; + getToolState = () => { return this.getState().tool; }; @@ -244,61 +214,24 @@ export class CanvasStateApi { return this.getState().session; }; getIsSelected = (id: string) => { - return this.getSelectedEntity()?.id === id; + return this.getState().selectedEntityIdentifier?.id === id; }; getLogLevel = () => { - return this.store.getState().system.consoleLogLevel; - }; - - // Read-only state, derived from nanostores - resetLastProgressEvent = () => { - $lastProgressEvent.set(null); + return this._store.getState().system.consoleLogLevel; }; // Read-write state, ephemeral interaction state - getIsDrawing = $isDrawing.get; - setIsDrawing = $isDrawing.set; - onIsDrawingChanged = $isDrawing.subscribe; - - getIsMouseDown = $isMouseDown.get; - setIsMouseDown = $isMouseDown.set; - onIsMouseDownChanged = $isMouseDown.subscribe; - - getLastAddedPoint = $lastAddedPoint.get; - setLastAddedPoint = $lastAddedPoint.set; - onLastAddedPointChanged = $lastAddedPoint.subscribe; - - getLastMouseDownPos = $lastMouseDownPos.get; - setLastMouseDownPos = $lastMouseDownPos.set; - onLastMouseDownPosChanged = $lastMouseDownPos.subscribe; - - getLastCursorPos = $lastCursorPos.get; - setLastCursorPos = $lastCursorPos.set; - onLastCursorPosChanged = $lastCursorPos.subscribe; - - getSpaceKey = $spaceKey.get; - setSpaceKey = $spaceKey.set; - onSpaceKeyChanged = $spaceKey.subscribe; - - getLastProgressEvent = $lastProgressEvent.get; - setLastProgressEvent = $lastProgressEvent.set; - onLastProgressEventChanged = $lastProgressEvent.subscribe; - - getAltKey = $alt.get; - onAltChanged = $alt.subscribe; - - getCtrlKey = $ctrl.get; - onCtrlChanged = $ctrl.subscribe; - - getMetaKey = $meta.get; - onMetaChanged = $meta.subscribe; - - getShiftKey = $shift.get; - onShiftChanged = buildSubscribe($shift.subscribe, 'onShiftChanged'); - - getShouldShowStagedImage = $shouldShowStagedImage.get; - onGetShouldShowStagedImageChanged = $shouldShowStagedImage.subscribe; - - setStageAttrs = $stageAttrs.set; - onStageAttrsChanged = buildSubscribe($stageAttrs.subscribe, 'onStageAttrsChanged'); + $isDrawing = $isDrawing; + $isMouseDown = $isMouseDown; + $lastAddedPoint = $lastAddedPoint; + $lastMouseDownPos = $lastMouseDownPos; + $lastCursorPos = $lastCursorPos; + $lastProgressEvent = $lastProgressEvent; + $spaceKey = $spaceKey; + $altKey = $alt; + $ctrlKey = $ctrl; + $metaKey = $meta; + $shiftKey = $shift; + $shouldShowStagedImage = $shouldShowStagedImage; + $stageAttrs = $stageAttrs; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts index 9d92f70714..742ac5ad80 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts @@ -139,17 +139,17 @@ export class CanvasTool { const stage = this.manager.stage; const renderedEntityCount: number = 1; // TODO(psyche): this.manager should be renderable entity count const toolState = this.manager.stateApi.getToolState(); - const currentFill = this.manager.stateApi.getCurrentFill(); - const selectedEntity = this.manager.stateApi.getSelectedEntity(); - const cursorPos = this.manager.stateApi.getLastCursorPos(); - const isDrawing = this.manager.stateApi.getIsDrawing(); - const isMouseDown = this.manager.stateApi.getIsMouseDown(); + const currentFill = this.manager.getCurrentFill(); + const selectedEntity = this.manager.getSelectedEntity(); + const cursorPos = this.manager.stateApi.$lastCursorPos.get(); + const isDrawing = this.manager.stateApi.$isDrawing.get(); + const isMouseDown = this.manager.stateApi.$isMouseDown.get(); const tool = toolState.selected; const isDrawableEntity = - selectedEntity?.type === 'regional_guidance' || - selectedEntity?.type === 'layer' || - selectedEntity?.type === 'inpaint_mask'; + selectedEntity?.state.type === 'regional_guidance' || + selectedEntity?.state.type === 'layer' || + selectedEntity?.state.type === 'inpaint_mask'; // Update the stage's pointer style if (tool === 'view') { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts index 25563bce23..4e8ab49b2e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts @@ -1,6 +1,5 @@ import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import type { Subscription } from 'features/controlLayers/konva/util'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { Coordinate, GetLoggingContext, Rect } from 'features/controlLayers/store/types'; import Konva from 'konva'; @@ -19,32 +18,50 @@ export class CanvasTransformer { static TRANSFORMER_NAME = `${CanvasTransformer.TYPE}:transformer`; static PROXY_RECT_NAME = `${CanvasTransformer.TYPE}:proxy_rect`; static BBOX_OUTLINE_NAME = `${CanvasTransformer.TYPE}:bbox_outline`; - static STROKE_COLOR = 'hsl(200deg 76% 59%)'; // `invokeBlue.400 + static STROKE_COLOR = 'hsl(200 76% 50% / 1)'; // invokeBlue.500 + static ANCHOR_FILL_COLOR = CanvasTransformer.STROKE_COLOR; + static ANCHOR_STROKE_COLOR = 'hsl(200 76% 77% / 1)'; // invokeBlue.200 + static RESIZE_ANCHOR_SIZE = 8; + static ROTATE_ANCHOR_FILL_COLOR = 'hsl(200 76% 95% / 1)'; // invokeBlue.50 + static ROTATE_ANCHOR_STROKE_COLOR = 'hsl(200 76% 40% / 1)'; // invokeBlue.700 + static ROTATE_ANCHOR_SIZE = 12; + static ANCHOR_CORNER_RADIUS_RATIO = 0.5; + static ANCHOR_STROKE_WIDTH = 2; + static ANCHOR_HIT_PADDING = 10; id: string; parent: CanvasLayer; manager: CanvasManager; log: Logger; getLoggingContext: GetLoggingContext; - subscriptions: Subscription[]; /** - * The current mode of the transformer: - * - 'transform': The entity can be moved, resized, and rotated - * - 'drag': The entity can only be moved - * - 'off': The transformer is disabled + * A list of subscriptions that should be cleaned up when the transformer is destroyed. */ - mode: 'transform' | 'drag' | 'off'; + subscriptions: (() => void)[] = []; /** - * Whether dragging is enabled. Dragging is enabled in both 'transform' and 'drag' modes. + * Whether the transformer is currently transforming the entity. */ - isDragEnabled: boolean; + isTransforming: boolean = false; /** - * Whether transforming is enabled. Transforming is enabled only in 'transform' mode. + * The current interaction mode of the transformer: + * - 'all': The entity can be moved, resized, and rotated. + * - 'drag': The entity can be moved. + * - 'off': The transformer is not interactable. */ - isTransformEnabled: boolean; + interactionMode: 'all' | 'drag' | 'off' = 'off'; + + /** + * Whether dragging is enabled. Dragging is enabled in both 'all' and 'drag' interaction modes. + */ + isDragEnabled: boolean = false; + + /** + * Whether transforming is enabled. Transforming is enabled only in 'all' interaction mode. + */ + isTransformEnabled: boolean = false; konva: { transformer: Konva.Transformer; @@ -59,11 +76,6 @@ export class CanvasTransformer { this.getLoggingContext = this.manager.buildGetLoggingContext(this); this.log = this.manager.buildLogger(this.getLoggingContext); - this.subscriptions = []; - - this.mode = 'off'; - this.isDragEnabled = false; - this.isTransformEnabled = false; this.konva = { bboxOutline: new Konva.Rect({ @@ -89,6 +101,35 @@ export class CanvasTransformer { padding: this.manager.getTransformerPadding(), // This is `invokeBlue.400` stroke: CanvasTransformer.STROKE_COLOR, + anchorFill: CanvasTransformer.ANCHOR_FILL_COLOR, + anchorStroke: CanvasTransformer.ANCHOR_STROKE_COLOR, + anchorStrokeWidth: CanvasTransformer.ANCHOR_STROKE_WIDTH, + anchorSize: CanvasTransformer.RESIZE_ANCHOR_SIZE, + anchorCornerRadius: CanvasTransformer.RESIZE_ANCHOR_SIZE * CanvasTransformer.ANCHOR_CORNER_RADIUS_RATIO, + anchorStyleFunc: (anchor) => { + if (anchor.hasName('rotater')) { + anchor.setAttrs({ + height: CanvasTransformer.ROTATE_ANCHOR_SIZE, + width: CanvasTransformer.ROTATE_ANCHOR_SIZE, + cornerRadius: CanvasTransformer.ROTATE_ANCHOR_SIZE * CanvasTransformer.ANCHOR_CORNER_RADIUS_RATIO, + fill: CanvasTransformer.ROTATE_ANCHOR_FILL_COLOR, + stroke: CanvasTransformer.ANCHOR_FILL_COLOR, + offsetX: CanvasTransformer.ROTATE_ANCHOR_SIZE / 2, + offsetY: CanvasTransformer.ROTATE_ANCHOR_SIZE / 2, + }); + } + anchor.hitFunc((context) => { + context.beginPath(); + context.rect( + -CanvasTransformer.ANCHOR_HIT_PADDING, + -CanvasTransformer.ANCHOR_HIT_PADDING, + anchor.width() + CanvasTransformer.ANCHOR_HIT_PADDING * 2, + anchor.height() + CanvasTransformer.ANCHOR_HIT_PADDING * 2 + ); + context.closePath(); + context.fillStrokeShape(anchor); + }); + }, // TODO(psyche): The konva Vector2D type is is apparently not compatible with the JSONObject type that the log // function expects. The in-house Coordinate type is functionally the same - `{x: number; y: number}` - and // TypeScript is happy with it. @@ -152,7 +193,7 @@ export class CanvasTransformer { // This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and // height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to // the nearest 45 degrees when shift is held. - if (this.manager.stateApi.getShiftKey()) { + if (this.manager.stateApi.$shiftKey.get()) { if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) { return oldBoundBox; } @@ -278,9 +319,9 @@ export class CanvasTransformer { }); }); this.konva.proxyRect.on('dragend', () => { - if (this.parent.isTransforming) { - // When the user cancels the transformation, we need to reset the layer, so we should not update the layer's - // positition while we are transforming - bail out early. + if (this.isTransforming) { + // If we are transforming the entity, we should not push the new position to the state. This will trigger a + // re-render of the entity and bork the transformation. return; } @@ -296,9 +337,9 @@ export class CanvasTransformer { this.subscriptions.push( // When the stage scale changes, we may need to re-scale some of the transformer's components. For example, // the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width. - this.manager.stateApi.onStageAttrsChanged((newAttrs, oldAttrs) => { - if (newAttrs.scale !== oldAttrs?.scale) { - this.scale(); + this.manager.stateApi.$stageAttrs.listen((newVal, oldVal) => { + if (newVal.scale !== oldVal.scale) { + this.syncScale(); } }) ); @@ -306,8 +347,24 @@ export class CanvasTransformer { this.subscriptions.push( // While the user holds shift, we want to snap rotation to 45 degree increments. Listen for the shift key state // and update the snap angles accordingly. - this.manager.stateApi.onShiftChanged((isPressed) => { - this.konva.transformer.rotationSnaps(isPressed ? [0, 45, 90, 135, 180, 225, 270, 315] : []); + this.manager.stateApi.$shiftKey.listen((newVal) => { + this.konva.transformer.rotationSnaps(newVal ? [0, 45, 90, 135, 180, 225, 270, 315] : []); + }) + ); + + this.subscriptions.push( + // When the selected tool changes, we need to update the transformer's interaction state. + this.manager.toolState.subscribe((newVal, oldVal) => { + if (newVal.selected !== oldVal.selected) { + this.syncInteractionState(); + } + }) + ); + + this.subscriptions.push( + // When the selected entity changes, we need to update the transformer's interaction state. + this.manager.selectedEntityIdentifier.subscribe(() => { + this.syncInteractionState(); }) ); } @@ -336,10 +393,48 @@ export class CanvasTransformer { }); }; + /** + * Syncs the transformer's interaction state with the application and entity's states. This is called when the entity + * is selected or deselected, or when the user changes the selected tool. + */ + syncInteractionState = () => { + this.log.trace('Syncing interaction state'); + + const toolState = this.manager.stateApi.getToolState(); + const isSelected = this.manager.stateApi.getIsSelected(this.parent.id); + + if (!this.parent.renderer.hasObjects()) { + // The layer is totally empty, we can just disable the layer + this.parent.konva.layer.listening(false); + this.setInteractionMode('off'); + return; + } + + if (isSelected && !this.isTransforming && toolState.selected === 'move') { + // We are moving this layer, it must be listening + this.parent.konva.layer.listening(true); + this.setInteractionMode('drag'); + } else if (isSelected && this.isTransforming) { + // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is + // active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected. + if (toolState.selected !== 'view') { + this.parent.konva.layer.listening(true); + this.setInteractionMode('all'); + } else { + this.parent.konva.layer.listening(false); + this.setInteractionMode('off'); + } + } else { + // The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff + this.parent.konva.layer.listening(false); + this.setInteractionMode('off'); + } + }; + /** * Updates the transformer's scale. This is called when the stage is scaled. */ - scale = () => { + syncScale = () => { const onePixel = this.manager.getScaledPixel(); const bboxPadding = this.manager.getScaledBboxPadding(); @@ -353,24 +448,53 @@ export class CanvasTransformer { this.konva.transformer.forceUpdate(); }; + startTransform = () => { + this.log.debug('Starting transform'); + this.isTransforming = true; + + // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or + // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening + // when the view tool is selected + const shouldListen = this.manager.stateApi.getToolState().selected !== 'view'; + this.parent.konva.layer.listening(shouldListen); + this.setInteractionMode('all'); + }; + + applyTransform = async () => { + this.log.debug('Applying transform'); + await this.parent.rasterize(); + this.stopTransform(); + }; + + stopTransform = () => { + this.log.debug('Stopping transform'); + + this.isTransforming = false; + this.setInteractionMode('off'); + this.parent.resetScale(); + this.parent.updatePosition(); + this.parent.updateBbox(); + this.syncInteractionState(); + }; + /** - * Sets the transformer to a specific mode. - * @param mode The mode to set the transformer to. The transformer can be in one of three modes: - * - 'transform': The entity can be moved, resized, and rotated - * - 'drag': The entity can only be moved - * - 'off': The transformer is disabled + * Sets the transformer to a specific interaction mode. + * @param interactionMode The mode to set the transformer to. The transformer can be in one of three modes: + * - 'all': The entity can be moved, resized, and rotated. + * - 'drag': The entity can be moved. + * - 'off': The transformer is not interactable. */ - setMode = (mode: 'transform' | 'drag' | 'off') => { - this.mode = mode; - if (mode === 'drag') { + setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => { + this.interactionMode = interactionMode; + if (interactionMode === 'drag') { this._enableDrag(); this._disableTransform(); this._showBboxOutline(); - } else if (mode === 'transform') { + } else if (interactionMode === 'all') { this._enableDrag(); this._enableTransform(); this._hideBboxOutline(); - } else if (mode === 'off') { + } else if (interactionMode === 'off') { this._disableDrag(); this._disableTransform(); this._hideBboxOutline(); @@ -411,13 +535,13 @@ export class CanvasTransformer { this.konva.bboxOutline.visible(false); }; - getNodes = () => [this.konva.transformer, this.konva.proxyRect, this.konva.bboxOutline]; + getNodes = () => [this.konva.bboxOutline, this.konva.proxyRect, this.konva.transformer]; repr = () => { return { id: this.id, type: CanvasTransformer.TYPE, - mode: this.mode, + mode: this.interactionMode, isTransformEnabled: this.isTransformEnabled, isDragEnabled: this.isDragEnabled, }; @@ -425,9 +549,9 @@ export class CanvasTransformer { destroy = () => { this.log.trace('Destroying transformer'); - for (const { name, unsubscribe } of this.subscriptions) { - this.log.trace({ name }, 'Cleaning up listener'); - unsubscribe(); + for (const cleanup of this.subscriptions) { + this.log.trace('Cleaning up listener'); + cleanup(); } this.konva.bboxOutline.destroy(); this.konva.transformer.destroy(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 9c07676a6b..3355052aaa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -26,7 +26,10 @@ import { BRUSH_SPACING_TARGET_SCALE, CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANV * @param stage The konva stage * @param setLastCursorPos The callback to store the cursor pos */ -const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: CanvasManager['stateApi']['setLastCursorPos']) => { +const updateLastCursorPos = ( + stage: Konva.Stage, + setLastCursorPos: CanvasManager['stateApi']['$lastCursorPos']['set'] +) => { const pos = getScaledCursorPosition(stage); if (!pos) { return null; @@ -112,22 +115,17 @@ const getLastPointOfLastLineOfEntity = ( }; export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { - const { stage, stateApi, getSelectedEntityAdapter } = manager; + const { stage, stateApi, getCurrentFill, getSelectedEntity } = manager; const { getToolState, - getCurrentFill, setTool, setToolBuffer, - setIsMouseDown, - setLastMouseDownPos, - getLastCursorPos, - setLastCursorPos, - // getLastAddedPoint, - setLastAddedPoint, - setStageAttrs, - getSelectedEntity, - getSpaceKey, - setSpaceKey, + $isMouseDown, + $lastMouseDownPos, + $lastCursorPos, + $lastAddedPoint, + $stageAttrs, + $spaceKey, getBbox, getSettings, onBrushWidthChanged, @@ -166,34 +164,31 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region mousedown stage.on('mousedown', async (e) => { - setIsMouseDown(true); + $isMouseDown.set(true); const toolState = getToolState(); - const pos = updateLastCursorPos(stage, setLastCursorPos); + const pos = updateLastCursorPos(stage, $lastCursorPos.set); const selectedEntity = getSelectedEntity(); - const selectedEntityAdapter = getSelectedEntityAdapter(); if ( pos && selectedEntity && - isDrawableEntity(selectedEntity) && - selectedEntityAdapter && - isDrawableEntityAdapter(selectedEntityAdapter) && - !getSpaceKey() && + isDrawableEntity(selectedEntity.state) && + !$spaceKey.get() && getIsPrimaryMouseDown(e) ) { - setLastMouseDownPos(pos); - const normalizedPoint = offsetCoord(pos, selectedEntity.position); + $lastMouseDownPos.set(pos); + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); if (toolState.selected === 'brush') { - const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected); + const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); if (e.evt.shiftKey && lastLinePoint) { // Create a straight line from the last line point - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('brush_line', true), type: 'brush_line', points: [ @@ -205,33 +200,33 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { ], strokeWidth: toolState.brush.width, color: getCurrentFill(), - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); } else { - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('brush_line', true), type: 'brush_line', points: [alignedPoint.x, alignedPoint.y], strokeWidth: toolState.brush.width, color: getCurrentFill(), - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); } - setLastAddedPoint(alignedPoint); + $lastAddedPoint.set(alignedPoint); } if (toolState.selected === 'eraser') { - const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected); + const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); if (e.evt.shiftKey && lastLinePoint) { // Create a straight line from the last line point - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('eraser_line', true), type: 'eraser_line', points: [ @@ -242,28 +237,28 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { alignedPoint.y, ], strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); } else { - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('eraser_line', true), type: 'eraser_line', points: [alignedPoint.x, alignedPoint.y], strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); } - setLastAddedPoint(alignedPoint); + $lastAddedPoint.set(alignedPoint); } if (toolState.selected === 'rect') { - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('rect', true), type: 'rect', x: Math.round(normalizedPoint.x), @@ -279,49 +274,41 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region mouseup stage.on('mouseup', async () => { - setIsMouseDown(false); - const pos = getLastCursorPos(); + $isMouseDown.set(false); + const pos = $lastCursorPos.get(); const selectedEntity = getSelectedEntity(); - const selectedEntityAdapter = getSelectedEntityAdapter(); - if ( - pos && - selectedEntity && - isDrawableEntity(selectedEntity) && - selectedEntityAdapter && - isDrawableEntityAdapter(selectedEntityAdapter) && - !getSpaceKey() - ) { + if (pos && selectedEntity && isDrawableEntity(selectedEntity.state) && !$spaceKey.get()) { const toolState = getToolState(); if (toolState.selected === 'brush') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer?.type === 'brush_line') { - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.commitBuffer(); } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } if (toolState.selected === 'eraser') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer?.type === 'eraser_line') { - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.commitBuffer(); } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } if (toolState.selected === 'rect') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer?.type === 'rect') { - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.commitBuffer(); } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } - setLastMouseDownPos(null); + $lastMouseDownPos.set(null); } manager.preview.tool.render(); @@ -330,94 +317,93 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region mousemove stage.on('mousemove', async (e) => { const toolState = getToolState(); - const pos = updateLastCursorPos(stage, setLastCursorPos); + const pos = updateLastCursorPos(stage, $lastCursorPos.set); const selectedEntity = getSelectedEntity(); - const selectedEntityAdapter = getSelectedEntityAdapter(); if ( pos && selectedEntity && - isDrawableEntity(selectedEntity) && - selectedEntityAdapter && - isDrawableEntityAdapter(selectedEntityAdapter) && - !getSpaceKey() && + isDrawableEntity(selectedEntity.state) && + selectedEntity.adapter && + isDrawableEntityAdapter(selectedEntity.adapter) && + !$spaceKey.get() && getIsPrimaryMouseDown(e) ) { if (toolState.selected === 'brush') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer) { if (drawingBuffer?.type === 'brush_line') { const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points)); if (nextPoint) { - const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position); + const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); - setLastAddedPoint(alignedPoint); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + $lastAddedPoint.set(alignedPoint); } } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } else { - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - const normalizedPoint = offsetCoord(pos, selectedEntity.position); + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('brush_line', true), type: 'brush_line', points: [alignedPoint.x, alignedPoint.y], strokeWidth: toolState.brush.width, color: getCurrentFill(), - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); - setLastAddedPoint(alignedPoint); + $lastAddedPoint.set(alignedPoint); } } if (toolState.selected === 'eraser') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer) { if (drawingBuffer.type === 'eraser_line') { const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points)); if (nextPoint) { - const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position); + const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); - setLastAddedPoint(alignedPoint); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + $lastAddedPoint.set(alignedPoint); } } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } else { - if (selectedEntityAdapter.renderer.buffer) { - await selectedEntityAdapter.renderer.commitBuffer(); + if (selectedEntity.adapter.renderer.buffer) { + await selectedEntity.adapter.renderer.commitBuffer(); } - const normalizedPoint = offsetCoord(pos, selectedEntity.position); + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); - await selectedEntityAdapter.renderer.setBuffer({ + await selectedEntity.adapter.renderer.setBuffer({ id: getObjectId('eraser_line', true), type: 'eraser_line', points: [alignedPoint.x, alignedPoint.y], strokeWidth: toolState.eraser.width, - clip: getClip(selectedEntity), + clip: getClip(selectedEntity.state), }); - setLastAddedPoint(alignedPoint); + $lastAddedPoint.set(alignedPoint); } } if (toolState.selected === 'rect') { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; + const drawingBuffer = selectedEntity.adapter.renderer.buffer; if (drawingBuffer) { if (drawingBuffer.type === 'rect') { - const normalizedPoint = offsetCoord(pos, selectedEntity.position); + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x); drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); } else { - await selectedEntityAdapter.renderer.clearBuffer(); + await selectedEntity.adapter.renderer.clearBuffer(); } } } @@ -427,39 +413,36 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region mouseleave stage.on('mouseleave', async (e) => { - const pos = updateLastCursorPos(stage, setLastCursorPos); - setLastCursorPos(null); - setLastMouseDownPos(null); + const pos = updateLastCursorPos(stage, $lastCursorPos.set); + $lastCursorPos.set(null); + $lastMouseDownPos.set(null); const selectedEntity = getSelectedEntity(); - const selectedEntityAdapter = getSelectedEntityAdapter(); const toolState = getToolState(); if ( pos && selectedEntity && - isDrawableEntity(selectedEntity) && - selectedEntityAdapter && - isDrawableEntityAdapter(selectedEntityAdapter) && - !getSpaceKey() && + isDrawableEntity(selectedEntity.state) && + !$spaceKey.get() && getIsPrimaryMouseDown(e) ) { - const drawingBuffer = selectedEntityAdapter.renderer.buffer; - const normalizedPoint = offsetCoord(pos, selectedEntity.position); + const drawingBuffer = selectedEntity.adapter.renderer.buffer; + const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + await selectedEntity.adapter.renderer.commitBuffer(); } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + await selectedEntity.adapter.renderer.commitBuffer(); } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect') { drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x); drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y); - await selectedEntityAdapter.renderer.setBuffer(drawingBuffer); - await selectedEntityAdapter.renderer.commitBuffer(); + await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); + await selectedEntity.adapter.renderer.commitBuffer(); } } @@ -503,7 +486,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { stage.scaleX(newScale); stage.scaleY(newScale); stage.position(newPos); - setStageAttrs({ + $stageAttrs.set({ position: newPos, dimensions: { width: stage.width(), height: stage.height() }, scale: newScale, @@ -516,7 +499,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region dragmove stage.on('dragmove', () => { - setStageAttrs({ + $stageAttrs.set({ position: { x: Math.floor(stage.x()), y: Math.floor(stage.y()) }, dimensions: { width: stage.width(), height: stage.height() }, scale: stage.scaleX(), @@ -528,7 +511,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { //#region dragend stage.on('dragend', () => { // Stage position should always be an integer, else we get fractional pixels which are blurry - setStageAttrs({ + $stageAttrs.set({ position: { x: Math.floor(stage.x()), y: Math.floor(stage.y()) }, dimensions: { width: stage.width(), height: stage.height() }, scale: stage.scaleX(), @@ -546,17 +529,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { } if (e.key === 'Escape') { // Cancel shape drawing on escape - setLastMouseDownPos(null); + $lastMouseDownPos.set(null); } else if (e.key === ' ') { // Select the view tool on space key down setToolBuffer(getToolState().selected); setTool('view'); - setSpaceKey(true); - setLastCursorPos(null); - setLastMouseDownPos(null); + $spaceKey.set(true); + $lastCursorPos.set(null); + $lastMouseDownPos.set(null); } else if (e.key === 'r') { - setLastCursorPos(null); - setLastMouseDownPos(null); + $lastCursorPos.set(null); + $lastMouseDownPos.set(null); manager.background.render(); // TODO(psyche): restore some kind of fit } @@ -576,7 +559,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { const toolBuffer = getToolState().selectedBuffer; setTool(toolBuffer ?? 'move'); setToolBuffer(null); - setSpaceKey(false); + $spaceKey.set(false); } manager.preview.tool.render(); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts index 350c998135..120799d2fa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts @@ -1,12 +1,17 @@ import { getImageDataTransparency } from 'common/util/arrayBuffer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import type { CanvasObjectState, Coordinate, GenerationMode, Rect, RgbaColor } from 'features/controlLayers/store/types'; +import type { + CanvasObjectState, + Coordinate, + GenerationMode, + Rect, + RgbaColor, +} from 'features/controlLayers/store/types'; import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Vector2d } from 'konva/lib/types'; import { customAlphabet } from 'nanoid'; -import type { WritableAtom } from 'nanostores'; import type { ImageDTO } from 'services/api/types'; import { assert } from 'tsafe'; @@ -64,7 +69,7 @@ export const alignCoordForTool = (coord: Coordinate, toolWidth: number): Coordin * Offsets a point by the given offset. The offset is subtracted from the point. * @param coord The coordinate to offset * @param offset The offset to apply - * @returns + * @returns */ export const offsetCoord = (coord: Coordinate, offset: Coordinate): Coordinate => { return { @@ -623,22 +628,6 @@ export function getObjectId(type: CanvasObjectState['type'], isBuffer?: boolean) } } -export type Subscription = { - name: string; - unsubscribe: () => void; -}; - -/** - * Builds a subscribe function for a nanostores atom. - * @param subscribe The subscribe function of the atom - * @param name The name of the atom - * @returns A subscribe function that returns an object with the name and unsubscribe function - */ -export const buildSubscribe = (subscribe: WritableAtom['subscribe'], name: string) => { - return (cb: Parameters['subscribe']>[0]): Subscription => { - return { - name, - unsubscribe: subscribe(cb), - }; - }; +export const getEmptyRect = (): Rect => { + return { x: 0, y: 0, width: 0, height: 0 }; };