diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 6b8aa4bf7d..97e887067a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -130,7 +130,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = stage.on('mouseenter', () => { const tool = getToolState().selected; stage.findOne(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region mousedown @@ -251,7 +251,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = setLastAddedPoint(pos); } } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region mouseup @@ -290,7 +290,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = setLastMouseDownPos(null); } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region mousemove @@ -396,7 +396,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = } } } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region mouseleave @@ -425,7 +425,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = } } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region wheel @@ -466,11 +466,11 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = stage.scaleY(newScale); stage.position(newPos); setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale }); - manager.konvaApi.renderBackground(); - manager.konvaApi.renderDocumentOverlay(); + manager.renderBackground(); + manager.renderDocumentOverlay(); } } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region dragmove @@ -482,9 +482,9 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = height: stage.height(), scale: stage.scaleX(), }); - manager.konvaApi.renderBackground(); - manager.konvaApi.renderDocumentOverlay(); - manager.konvaApi.renderToolPreview(); + manager.renderBackground(); + manager.renderDocumentOverlay(); + manager.renderToolPreview(); }); //#region dragend @@ -497,7 +497,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = height: stage.height(), scale: stage.scaleX(), }); - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }); //#region key @@ -518,12 +518,12 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = setTool('view'); setSpaceKey(true); } else if (e.key === 'r') { - manager.konvaApi.fitDocumentToStage(); - manager.konvaApi.renderToolPreview(); - manager.konvaApi.renderBackground(); - manager.konvaApi.renderDocumentOverlay(); + manager.fitDocumentToStage(); + manager.renderToolPreview(); + manager.renderBackground(); + manager.renderDocumentOverlay(); } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }; window.addEventListener('keydown', onKeyDown); @@ -541,7 +541,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = setToolBuffer(null); setSpaceKey(false); } - manager.konvaApi.renderToolPreview(); + manager.renderToolPreview(); }; window.addEventListener('keyup', onKeyUp); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts index c927dd5edb..56c26d2cb3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts @@ -1,18 +1,17 @@ import { getImageDataTransparency } from 'common/util/arrayBuffer'; +import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants'; +import { KonvaBackground } from 'features/controlLayers/konva/renderers/background'; +import { KonvaPreview } from 'features/controlLayers/konva/renderers/preview'; import { konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util'; import type { - BrushLine, BrushLineAddedArg, CanvasEntity, CanvasV2State, - EraserLine, EraserLineAddedArg, GenerationMode, - ImageObject, PointAddedToLineArg, PosChangedArg, Rect, - RectShape, RectShapeAddedArg, RgbaColor, StageAttrs, @@ -21,96 +20,16 @@ import type { import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers'; import type Konva from 'konva'; import type { Vector2d } from 'konva/lib/types'; -import { atom } from 'nanostores'; import { getImageDTO as defaultGetImageDTO, uploadImage as defaultUploadImage } from 'services/api/endpoints/images'; import type { ImageCategory, ImageDTO } from 'services/api/types'; import { assert } from 'tsafe'; -export type BrushLineObjectRecord = { - id: string; - type: BrushLine['type']; - konvaLine: Konva.Line; - konvaLineGroup: Konva.Group; -}; +import { KonvaControlAdapter } from './renderers/controlAdapters'; +import { KonvaInpaintMask } from './renderers/inpaintMask'; +import { KonvaLayerAdapter } from './renderers/layers'; +import { KonvaRegion } from './renderers/regions'; -export type EraserLineObjectRecord = { - id: string; - type: EraserLine['type']; - konvaLine: Konva.Line; - konvaLineGroup: Konva.Group; -}; - -export type RectShapeObjectRecord = { - id: string; - type: RectShape['type']; - konvaRect: Konva.Rect; -}; - -export type ImageObjectRecord = { - id: string; - type: ImageObject['type']; - konvaImageGroup: Konva.Group; - konvaPlaceholderGroup: Konva.Group; - konvaPlaceholderRect: Konva.Rect; - konvaPlaceholderText: Konva.Text; - imageName: string | null; - konvaImage: Konva.Image | null; // The image is loaded asynchronously, so it may not be available immediately - isLoading: boolean; - isError: boolean; -}; - -type ObjectRecord = BrushLineObjectRecord | EraserLineObjectRecord | RectShapeObjectRecord | ImageObjectRecord; - -type KonvaApi = { - renderRegions: () => void; - renderLayers: () => void; - renderControlAdapters: () => void; - renderInpaintMask: () => void; - renderBbox: () => void; - renderDocumentOverlay: () => void; - renderBackground: () => void; - renderToolPreview: () => void; - renderStagingArea: () => void; - arrangeEntities: () => void; - fitDocumentToStage: () => void; - fitStageToContainer: () => void; -}; - -type BackgroundLayer = { - layer: Konva.Layer; -}; - -type PreviewLayer = { - layer: Konva.Layer; - bbox: { - group: Konva.Group; - rect: Konva.Rect; - transformer: Konva.Transformer; - }; - tool: { - group: Konva.Group; - brush: { - group: Konva.Group; - fill: Konva.Circle; - innerBorder: Konva.Circle; - outerBorder: Konva.Circle; - }; - rect: { - rect: Konva.Rect; - }; - }; - documentOverlay: { - group: Konva.Group; - innerRect: Konva.Rect; - outerRect: Konva.Rect; - }; - stagingArea: { - group: Konva.Group; - image: ImageObjectRecord | null; - }; -}; - -type StateApi = { +export type StateApi = { getToolState: () => CanvasV2State['tool']; getCurrentFill: () => RgbaColor; setTool: (tool: Tool) => void; @@ -174,21 +93,25 @@ type Util = { export class KonvaNodeManager { stage: Konva.Stage; container: HTMLDivElement; - adapters: Map; + controlAdapters: Map; + layers: Map; + regions: Map; + inpaintMask: KonvaInpaintMask | null; util: Util; - _background: BackgroundLayer | null; - _preview: PreviewLayer | null; - _konvaApi: KonvaApi | null; - _stateApi: StateApi | null; + stateApi: StateApi; + preview: KonvaPreview; + background: KonvaBackground; constructor( stage: Konva.Stage, container: HTMLDivElement, + stateApi: StateApi, getImageDTO: Util['getImageDTO'] = defaultGetImageDTO, uploadImage: Util['uploadImage'] = defaultUploadImage ) { this.stage = stage; this.container = container; + this.stateApi = stateApi; this.util = { getImageDTO, uploadImage, @@ -199,83 +122,183 @@ export class KonvaNodeManager { getCompositeLayerStageClone: this._getCompositeLayerStageClone.bind(this), getGenerationMode: this._getGenerationMode.bind(this), }; - this._konvaApi = null; - this._preview = null; - this._background = null; - this._stateApi = null; - this.adapters = new Map(); + this.preview = new KonvaPreview( + this.stage, + this.stateApi.getBbox, + this.stateApi.onBboxTransformed, + this.stateApi.getShiftKey, + this.stateApi.getCtrlKey, + this.stateApi.getMetaKey, + this.stateApi.getAltKey + ); + this.background = new KonvaBackground(); + this.layers = new Map(); + this.regions = new Map(); + this.controlAdapters = new Map(); + this.inpaintMask = null; } - add(entity: CanvasEntity, konvaLayer: Konva.Layer, konvaObjectGroup: Konva.Group): KonvaEntityAdapter { - const adapter = new KonvaEntityAdapter(entity, konvaLayer, konvaObjectGroup, this); - this.adapters.set(adapter.id, adapter); - return adapter; - } + renderLayers() { + const { entities } = this.stateApi.getLayersState(); + const toolState = this.stateApi.getToolState(); - get(id: string): KonvaEntityAdapter | undefined { - return this.adapters.get(id); - } + for (const adapter of this.layers.values()) { + if (!entities.find((l) => l.id === adapter.id)) { + adapter.destroy(); + this.layers.delete(adapter.id); + } + } - getAll(type?: CanvasEntity['type']): KonvaEntityAdapter[] { - if (type) { - return Array.from(this.adapters.values()).filter((adapter) => adapter.entityType === type); - } else { - return Array.from(this.adapters.values()); + for (const entity of entities) { + let adapter = this.layers.get(entity.id); + if (!adapter) { + adapter = new KonvaLayerAdapter(entity, this.stateApi.onPosChanged); + this.layers.set(adapter.id, adapter); + this.stage.add(adapter.konvaLayer); + } + adapter.render(entity, toolState.selected); } } - destroy(id: string): boolean { - const adapter = this.get(id); - if (!adapter) { - return false; + renderRegions() { + const { entities } = this.stateApi.getRegionsState(); + const maskOpacity = this.stateApi.getMaskOpacity(); + const toolState = this.stateApi.getToolState(); + const selectedEntity = this.stateApi.getSelectedEntity(); + + // Destroy the konva nodes for nonexistent entities + for (const adapter of this.regions.values()) { + if (!entities.find((rg) => rg.id === adapter.id)) { + adapter.destroy(); + this.regions.delete(adapter.id); + } + } + + for (const entity of entities) { + let adapter = this.regions.get(entity.id); + if (!adapter) { + adapter = new KonvaRegion(entity, this.stateApi.onPosChanged); + this.regions.set(adapter.id, adapter); + this.stage.add(adapter.konvaLayer); + } + adapter.render(entity, toolState.selected, selectedEntity, maskOpacity); } - adapter.konvaLayer.destroy(); - return this.adapters.delete(id); } - set konvaApi(konvaApi: KonvaApi) { - this._konvaApi = konvaApi; + renderInpaintMask() { + const inpaintMaskState = this.stateApi.getInpaintMaskState(); + if (!this.inpaintMask) { + this.inpaintMask = new KonvaInpaintMask(inpaintMaskState, this.stateApi.onPosChanged); + this.stage.add(this.inpaintMask.konvaLayer); + } + const toolState = this.stateApi.getToolState(); + const selectedEntity = this.stateApi.getSelectedEntity(); + const maskOpacity = this.stateApi.getMaskOpacity(); + + this.inpaintMask.render(inpaintMaskState, toolState.selected, selectedEntity, maskOpacity); } - get konvaApi(): KonvaApi { - assert(this._konvaApi !== null, 'Konva API has not been set'); - return this._konvaApi; + renderControlAdapters() { + const { entities } = this.stateApi.getControlAdaptersState(); + + for (const adapter of this.controlAdapters.values()) { + if (!entities.find((ca) => ca.id === adapter.id)) { + adapter.destroy(); + this.controlAdapters.delete(adapter.id); + } + } + + for (const entity of entities) { + let adapter = this.controlAdapters.get(entity.id); + if (!adapter) { + adapter = new KonvaControlAdapter(entity); + this.controlAdapters.set(adapter.id, adapter); + this.stage.add(adapter.konvaLayer); + } + adapter.render(entity); + } } - set preview(preview: PreviewLayer) { - this._preview = preview; + arrangeEntities() { + const { getLayersState, getControlAdaptersState, getRegionsState } = this.stateApi; + const layers = getLayersState().entities; + const controlAdapters = getControlAdaptersState().entities; + const regions = getRegionsState().entities; + let zIndex = 0; + this.background.konvaLayer.zIndex(++zIndex); + for (const layer of layers) { + this.layers.get(layer.id)?.konvaLayer.zIndex(++zIndex); + } + for (const ca of controlAdapters) { + this.controlAdapters.get(ca.id)?.konvaLayer.zIndex(++zIndex); + } + for (const rg of regions) { + this.regions.get(rg.id)?.konvaLayer.zIndex(++zIndex); + } + this.inpaintMask?.konvaLayer.zIndex(++zIndex); + this.preview.konvaLayer.zIndex(++zIndex); } - get preview(): PreviewLayer { - assert(this._preview !== null, 'Konva preview layer has not been set'); - return this._preview; + renderDocumentOverlay() { + this.preview.renderDocumentOverlay(this.stage, this.stateApi.getDocument()); } - set background(background: BackgroundLayer) { - this._background = background; + renderBbox() { + this.preview.renderBbox(this.stateApi.getBbox(), this.stateApi.getToolState()); } - get background(): BackgroundLayer { - assert(this._background !== null, 'Konva background layer has not been set'); - return this._background; + renderToolPreview() { + this.preview.renderToolPreview( + this.stage, + 1, + this.stateApi.getToolState(), + this.stateApi.getCurrentFill(), + this.stateApi.getSelectedEntity(), + this.stateApi.getLastCursorPos(), + this.stateApi.getLastMouseDownPos(), + this.stateApi.getIsDrawing(), + this.stateApi.getIsMouseDown() + ); } - set stateApi(stateApi: StateApi) { - this._stateApi = stateApi; + fitDocumentToStage(): void { + const { getDocument, setStageAttrs } = this.stateApi; + const document = getDocument(); + // Fit & center the document on the stage + const width = this.stage.width(); + const height = this.stage.height(); + const docWidthWithBuffer = document.width + DOCUMENT_FIT_PADDING_PX * 2; + const docHeightWithBuffer = document.height + DOCUMENT_FIT_PADDING_PX * 2; + const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1); + const x = (width - docWidthWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale; + const y = (height - docHeightWithBuffer * scale) / 2 + DOCUMENT_FIT_PADDING_PX * scale; + this.stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale }); + setStageAttrs({ x, y, width, height, scale }); } - get stateApi(): StateApi { - assert(this._stateApi !== null, 'State API has not been set'); - return this._stateApi; + fitStageToContainer(): void { + this.stage.width(this.container.offsetWidth); + this.stage.height(this.container.offsetHeight); + this.stateApi.setStageAttrs({ + x: this.stage.x(), + y: this.stage.y(), + width: this.stage.width(), + height: this.stage.height(), + scale: this.stage.scaleX(), + }); + this.renderBackground(); + this.renderDocumentOverlay(); } - _getMaskLayerClone(arg: { id: string }): Konva.Layer { - const { id } = arg; - const adapter = this.get(id); - assert(adapter, `Adapter for entity ${id} not found`); + renderBackground() { + this.background.renderBackground(this.stage); + } - const layerClone = adapter.konvaLayer.clone(); - const objectGroupClone = adapter.konvaObjectGroup.clone(); + _getMaskLayerClone(): Konva.Layer { + assert(this.inpaintMask, 'Inpaint mask layer has not been set'); + + const layerClone = this.inpaintMask.konvaLayer.clone(); + const objectGroupClone = this.inpaintMask.konvaObjectGroup.clone(); layerClone.destroyChildren(); layerClone.add(objectGroupClone); @@ -392,7 +415,7 @@ export class KonvaNodeManager { async _getImageSourceImage(arg: { bbox?: Rect; preview?: boolean }): Promise { const { bbox, preview = false } = arg; - const { imageCache } = this.stateApi.getLayersState(); + // const { imageCache } = this.stateApi.getLayersState(); // if (imageCache) { // const imageDTO = await this.util.getImageDTO(imageCache.name); @@ -416,72 +439,3 @@ export class KonvaNodeManager { return imageDTO; } } - -export class KonvaEntityAdapter { - id: string; - entityType: CanvasEntity['type']; - konvaLayer: Konva.Layer; // Every entity is associated with a konva layer - konvaObjectGroup: Konva.Group; // Every entity's nodes are part of an object group - objectRecords: Map; - manager: KonvaNodeManager; - - constructor(entity: CanvasEntity, konvaLayer: Konva.Layer, konvaObjectGroup: Konva.Group, manager: KonvaNodeManager) { - this.id = entity.id; - this.entityType = entity.type; - this.konvaLayer = konvaLayer; - this.konvaObjectGroup = konvaObjectGroup; - this.objectRecords = new Map(); - this.manager = manager; - this.konvaLayer.add(this.konvaObjectGroup); - this.manager.stage.add(this.konvaLayer); - } - - add(objectRecord: T): T { - this.objectRecords.set(objectRecord.id, objectRecord); - if (objectRecord.type === 'brush_line' || objectRecord.type === 'eraser_line') { - objectRecord.konvaLineGroup.add(objectRecord.konvaLine); - this.konvaObjectGroup.add(objectRecord.konvaLineGroup); - } else if (objectRecord.type === 'rect_shape') { - this.konvaObjectGroup.add(objectRecord.konvaRect); - } else if (objectRecord.type === 'image') { - objectRecord.konvaPlaceholderGroup.add(objectRecord.konvaPlaceholderRect); - objectRecord.konvaPlaceholderGroup.add(objectRecord.konvaPlaceholderText); - objectRecord.konvaImageGroup.add(objectRecord.konvaPlaceholderGroup); - this.konvaObjectGroup.add(objectRecord.konvaImageGroup); - } - return objectRecord; - } - - get(id: string): T | undefined { - return this.objectRecords.get(id) as T | undefined; - } - - getAll(): T[] { - return Array.from(this.objectRecords.values()) as T[]; - } - - destroy(id: string): boolean { - const record = this.get(id); - if (!record) { - return false; - } - if (record.type === 'brush_line' || record.type === 'eraser_line') { - record.konvaLineGroup.destroy(); - } else if (record.type === 'rect_shape') { - record.konvaRect.destroy(); - } else if (record.type === 'image') { - record.konvaImageGroup.destroy(); - } - return this.objectRecords.delete(id); - } -} - -const $nodeManager = atom(null); -export const setNodeManager = (manager: KonvaNodeManager) => { - $nodeManager.set(manager); -}; -export const getNodeManager = () => { - const nodeManager = $nodeManager.get(); - assert(nodeManager, 'Konva node manager not initialized'); - return nodeManager; -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts index 2fa3039932..dea5aba2a3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts @@ -24,7 +24,7 @@ export const getArrangeEntities = (manager: KonvaNodeManager) => { manager.get(rg.id)?.konvaLayer.zIndex(++zIndex); } manager.get('inpaint_mask')?.konvaLayer.zIndex(++zIndex); - manager.preview.layer.zIndex(++zIndex); + manager.preview.konvaLayer.zIndex(++zIndex); } return arrangeEntities; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts index 15aa97e096..3e1dd6de06 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts @@ -120,3 +120,86 @@ export const getRenderBackground = (manager: KonvaNodeManager) => { return renderBackground; }; + +export class KonvaBackground { + konvaLayer: Konva.Layer; + + constructor() { + this.konvaLayer = new Konva.Layer({ listening: false }); + } + + renderBackground(stage: Konva.Stage): void { + this.konvaLayer.zIndex(0); + const scale = stage.scaleX(); + const gridSpacing = getGridSpacing(scale); + const x = stage.x(); + const y = stage.y(); + const width = stage.width(); + const height = stage.height(); + const stageRect = { + x1: 0, + y1: 0, + x2: width, + y2: height, + }; + + const gridOffset = { + x: Math.ceil(x / scale / gridSpacing) * gridSpacing, + y: Math.ceil(y / scale / gridSpacing) * gridSpacing, + }; + + const gridRect = { + x1: -gridOffset.x, + y1: -gridOffset.y, + x2: width / scale - gridOffset.x + gridSpacing, + y2: height / scale - gridOffset.y + gridSpacing, + }; + + const gridFullRect = { + x1: Math.min(stageRect.x1, gridRect.x1), + y1: Math.min(stageRect.y1, gridRect.y1), + x2: Math.max(stageRect.x2, gridRect.x2), + y2: Math.max(stageRect.y2, gridRect.y2), + }; + + // find the x & y size of the grid + const xSize = gridFullRect.x2 - gridFullRect.x1; + const ySize = gridFullRect.y2 - gridFullRect.y1; + // compute the number of steps required on each axis. + const xSteps = Math.round(xSize / gridSpacing) + 1; + const ySteps = Math.round(ySize / gridSpacing) + 1; + + const strokeWidth = 1 / scale; + let _x = 0; + let _y = 0; + + this.konvaLayer.destroyChildren(); + + for (let i = 0; i < xSteps; i++) { + _x = gridFullRect.x1 + i * gridSpacing; + this.konvaLayer.add( + new Konva.Line({ + x: _x, + y: gridFullRect.y1, + points: [0, 0, 0, ySize], + stroke: _x % 64 ? fineGridLineColor : baseGridLineColor, + strokeWidth, + listening: false, + }) + ); + } + for (let i = 0; i < ySteps; i++) { + _y = gridFullRect.y1 + i * gridSpacing; + this.konvaLayer.add( + new Konva.Line({ + x: gridFullRect.x1, + y: _y, + points: [0, 0, xSize, 0], + stroke: _y % 64 ? fineGridLineColor : baseGridLineColor, + strokeWidth, + listening: false, + }) + ); + } + } +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts index db61ced5db..81c392cb9e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts @@ -1,81 +1,50 @@ import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters'; -import { CA_LAYER_IMAGE_NAME, CA_LAYER_NAME, CA_LAYER_OBJECT_GROUP_NAME } from 'features/controlLayers/konva/naming'; -import type { ImageObjectRecord, KonvaEntityAdapter, KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import { - createImageObjectGroup, - createObjectGroup, - updateImageSource, -} from 'features/controlLayers/konva/renderers/objects'; +import { getObjectGroupId } from 'features/controlLayers/konva/naming'; import type { ControlAdapterEntity } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { isEqual } from 'lodash-es'; -import { assert } from 'tsafe'; +import { v4 as uuidv4 } from 'uuid'; -/** - * Gets a control adapter entity's konva nodes and entity adapter, creating them if they do not exist. - * @param manager The konva node manager - * @param entity The control adapter layer state - */ -const getControlAdapter = (manager: KonvaNodeManager, entity: ControlAdapterEntity): KonvaEntityAdapter => { - const adapter = manager.get(entity.id); - if (adapter) { - return adapter; - } - const konvaLayer = new Konva.Layer({ - id: entity.id, - name: CA_LAYER_NAME, - imageSmoothingEnabled: false, - listening: false, - }); - const konvaObjectGroup = createObjectGroup(konvaLayer, CA_LAYER_OBJECT_GROUP_NAME); - return manager.add(entity, konvaLayer, konvaObjectGroup); -}; +import { KonvaImage } from './objects'; -/** - * Renders a control adapter. - * @param manager The konva node manager - * @param entity The control adapter entity state - */ -export const renderControlAdapter = async (manager: KonvaNodeManager, entity: ControlAdapterEntity): Promise => { - const adapter = getControlAdapter(manager, entity); - const imageObject = entity.processedImageObject ?? entity.imageObject; +export class KonvaControlAdapter { + id: string; + konvaLayer: Konva.Layer; + konvaObjectGroup: Konva.Group; + konvaImageObject: KonvaImage | null; - if (!imageObject) { - // The user has deleted/reset the image - adapter.getAll().forEach((entry) => { - adapter.destroy(entry.id); + constructor(entity: ControlAdapterEntity) { + const { id } = entity; + this.id = id; + this.konvaLayer = new Konva.Layer({ + id, + imageSmoothingEnabled: false, + listening: false, }); - return; + this.konvaObjectGroup = new Konva.Group({ + id: getObjectGroupId(this.konvaLayer.id(), uuidv4()), + listening: false, + }); + this.konvaLayer.add(this.konvaObjectGroup); + this.konvaImageObject = null; } - let entry = adapter.getAll()[0]; - const opacity = entity.opacity; - const visible = entity.isEnabled; - const filters = entity.filter === 'LightnessToAlphaFilter' ? [LightnessToAlphaFilter] : []; - - if (!entry) { - entry = await createImageObjectGroup({ - adapter: adapter, - obj: imageObject, - name: CA_LAYER_IMAGE_NAME, - onLoad: (konvaImage) => { - konvaImage.filters(filters); - konvaImage.cache(); - konvaImage.opacity(opacity); - konvaImage.visible(visible); - }, - }); - } else { - if (entry.isLoading || entry.isError) { + async render(entity: ControlAdapterEntity) { + const imageObject = entity.processedImageObject ?? entity.imageObject; + if (!imageObject) { + if (this.konvaImageObject) { + this.konvaImageObject.destroy(); + } return; } - assert(entry.konvaImage, `Image entry ${entry.id} must have a konva image if it is not loading or in error state`); - const imageSource = entry.konvaImage.image(); - assert(imageSource instanceof HTMLImageElement, `Image source must be an HTMLImageElement`); - if (imageSource.id !== imageObject.image.name) { - updateImageSource({ - objectRecord: entry, - image: imageObject.image, + + const opacity = entity.opacity; + const visible = entity.isEnabled; + const filters = entity.filter === 'LightnessToAlphaFilter' ? [LightnessToAlphaFilter] : []; + + if (!this.konvaImageObject) { + this.konvaImageObject = await new KonvaImage({ + imageObject, onLoad: (konvaImage) => { konvaImage.filters(filters); konvaImage.cache(); @@ -83,37 +52,25 @@ export const renderControlAdapter = async (manager: KonvaNodeManager, entity: Co konvaImage.visible(visible); }, }); - } else { - if (!isEqual(entry.konvaImage.filters(), filters)) { - entry.konvaImage.filters(filters); - entry.konvaImage.cache(); - } - entry.konvaImage.opacity(opacity); - entry.konvaImage.visible(visible); + this.konvaObjectGroup.add(this.konvaImageObject.konvaImageGroup); } - } -}; - -/** - * Gets a function to render all control adapters. - * @param manager The konva node manager - * @returns A function to render all control adapters - */ -export const getRenderControlAdapters = (manager: KonvaNodeManager) => { - const { getControlAdaptersState } = manager.stateApi; - - function renderControlAdapters(): void { - const { entities } = getControlAdaptersState(); - // Destroy nonexistent layers - for (const adapters of manager.getAll('control_adapter')) { - if (!entities.find((ca) => ca.id === adapters.id)) { - manager.destroy(adapters.id); - } + if (this.konvaImageObject.isLoading || this.konvaImageObject.isError) { + return; } - for (const entity of entities) { - renderControlAdapter(manager, entity); + if (this.konvaImageObject.imageName !== imageObject.image.name) { + this.konvaImageObject.updateImageSource(imageObject.image.name); + } + if (this.konvaImageObject.konvaImage) { + if (!isEqual(this.konvaImageObject.konvaImage.filters(), filters)) { + this.konvaImageObject.konvaImage.filters(filters); + this.konvaImageObject.konvaImage.cache(); + } + this.konvaImageObject.konvaImage.opacity(opacity); + this.konvaImageObject.konvaImage.visible(visible); } } - return renderControlAdapters; -}; + destroy(): void { + this.konvaLayer.destroy(); + } +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts index fc7bed9a64..780121b20a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts @@ -1,162 +1,134 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers'; -import { - COMPOSITING_RECT_NAME, - INPAINT_MASK_LAYER_BRUSH_LINE_NAME, - INPAINT_MASK_LAYER_ERASER_LINE_NAME, - INPAINT_MASK_LAYER_NAME, - INPAINT_MASK_LAYER_OBJECT_GROUP_NAME, - INPAINT_MASK_LAYER_RECT_SHAPE_NAME, -} from 'features/controlLayers/konva/naming'; -import type { KonvaEntityAdapter, KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; +import { getObjectGroupId } from 'features/controlLayers/konva/naming'; +import type { StateApi } from 'features/controlLayers/konva/nodeManager'; import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/bbox'; -import { - createObjectGroup, - getBrushLine, - getEraserLine, - getRectShape, -} from 'features/controlLayers/konva/renderers/objects'; +import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects'; import { mapId } from 'features/controlLayers/konva/util'; -import type { CanvasEntity, InpaintMaskEntity, PosChangedArg } from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, InpaintMaskEntity, Tool } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import { assert } from 'tsafe'; +import { v4 as uuidv4 } from 'uuid'; -/** - * Creates the "compositing rect" for the inpaint mask. - * @param konvaLayer The konva layer - */ -const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { - const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false }); - konvaLayer.add(compositingRect); - return compositingRect; -}; +export class KonvaInpaintMask { + id: string; + konvaLayer: Konva.Layer; + konvaObjectGroup: Konva.Group; + compositingRect: Konva.Rect; + objects: Map; -/** - * Gets the singleton inpaint mask entity's konva nodes and entity adapter, creating them if they do not exist. - * @param manager The konva node manager - * @param entityState The inpaint mask entity state - * @param onPosChanged Callback for when the position changes (e.g. the entity is dragged) - * @returns The konva entity adapter for the inpaint mask - */ -const getInpaintMask = ( - manager: KonvaNodeManager, - entityState: InpaintMaskEntity, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): KonvaEntityAdapter => { - const adapter = manager.get(entityState.id); - if (adapter) { - return adapter; - } - // This layer hasn't been added to the konva state yet - const konvaLayer = new Konva.Layer({ - id: entityState.id, - name: INPAINT_MASK_LAYER_NAME, - draggable: true, - dragDistance: 0, - }); + constructor(entity: InpaintMaskEntity, onPosChanged: StateApi['onPosChanged']) { + this.id = entity.id; - // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing - // the position - we do not need to call this on the `dragmove` event. - if (onPosChanged) { - konvaLayer.on('dragend', function (e) { - onPosChanged({ id: entityState.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'inpaint_mask'); + this.konvaLayer = new Konva.Layer({ + id: entity.id, + draggable: true, + dragDistance: 0, }); + + // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing + // the position - we do not need to call this on the `dragmove` event. + this.konvaLayer.on('dragend', function (e) { + onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'inpaint_mask'); + }); + this.konvaObjectGroup = new Konva.Group({ + id: getObjectGroupId(this.konvaLayer.id(), uuidv4()), + listening: false, + }); + this.konvaLayer.add(this.konvaObjectGroup); + this.compositingRect = new Konva.Rect({ listening: false }); + this.konvaLayer.add(this.compositingRect); + this.objects = new Map(); } - const konvaObjectGroup = createObjectGroup(konvaLayer, INPAINT_MASK_LAYER_OBJECT_GROUP_NAME); - return manager.add(entityState, konvaLayer, konvaObjectGroup); -}; - -/** - * Gets a function to render the inpaint mask. - * @param manager The konva node manager - * @returns A function to render the inpaint mask - */ -export const getRenderInpaintMask = (manager: KonvaNodeManager) => { - const { getInpaintMaskState, getMaskOpacity, getToolState, getSelectedEntity, onPosChanged } = manager.stateApi; - - function renderInpaintMask(): void { - const entity = getInpaintMaskState(); - const globalMaskLayerOpacity = getMaskOpacity(); - const toolState = getToolState(); - const selectedEntity = getSelectedEntity(); - const adapter = getInpaintMask(manager, entity, onPosChanged); + destroy(): void { + this.konvaLayer.destroy(); + } + async render( + inpaintMaskState: InpaintMaskEntity, + selectedTool: Tool, + selectedEntityIdentifier: CanvasEntityIdentifier | null, + maskOpacity: number + ) { // Update the layer's position and listening state - adapter.konvaLayer.setAttrs({ - listening: toolState.selected === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events - x: Math.floor(entity.x), - y: Math.floor(entity.y), + this.konvaLayer.setAttrs({ + listening: selectedTool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events + x: Math.floor(inpaintMaskState.x), + y: Math.floor(inpaintMaskState.y), }); // Convert the color to a string, stripping the alpha - the object group will handle opacity. - const rgbColor = rgbColorToString(entity.fill); + const rgbColor = rgbColorToString(inpaintMaskState.fill); // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. let groupNeedsCache = false; - const objectIds = entity.objects.map(mapId); + const objectIds = inpaintMaskState.objects.map(mapId); // Destroy any objects that are no longer in state - for (const objectRecord of adapter.getAll()) { - if (!objectIds.includes(objectRecord.id)) { - adapter.destroy(objectRecord.id); + for (const object of this.objects.values()) { + if (!objectIds.includes(object.id)) { + object.destroy(); groupNeedsCache = true; } } - for (const obj of entity.objects) { + for (const obj of inpaintMaskState.objects) { if (obj.type === 'brush_line') { - const objectRecord = getBrushLine(adapter, obj, INPAINT_MASK_LAYER_BRUSH_LINE_NAME); + let brushLine = this.objects.get(obj.id); + assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); - // Only update the points if they have changed. The point values are never mutated, they are only added to the - // array, so checking the length is sufficient to determine if we need to re-cache. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); + if (!brushLine) { + brushLine = new KonvaBrushLine({ brushLine: obj }); + this.objects.set(brushLine.id, brushLine); + this.konvaLayer.add(brushLine.konvaLineGroup); groupNeedsCache = true; } - // Only update the color if it has changed. - if (objectRecord.konvaLine.stroke() !== rgbColor) { - objectRecord.konvaLine.stroke(rgbColor); + + if (obj.points.length !== brushLine.konvaLine.points().length) { + brushLine.konvaLine.points(obj.points); groupNeedsCache = true; } } else if (obj.type === 'eraser_line') { - const objectRecord = getEraserLine(adapter, obj, INPAINT_MASK_LAYER_ERASER_LINE_NAME); + let eraserLine = this.objects.get(obj.id); + assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); - // Only update the points if they have changed. The point values are never mutated, they are only added to the - // array, so checking the length is sufficient to determine if we need to re-cache. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); + if (!eraserLine) { + eraserLine = new KonvaEraserLine({ eraserLine: obj }); + this.objects.set(eraserLine.id, eraserLine); + this.konvaLayer.add(eraserLine.konvaLineGroup); groupNeedsCache = true; } - // Only update the color if it has changed. - if (objectRecord.konvaLine.stroke() !== rgbColor) { - objectRecord.konvaLine.stroke(rgbColor); + + if (obj.points.length !== eraserLine.konvaLine.points().length) { + eraserLine.konvaLine.points(obj.points); groupNeedsCache = true; } } else if (obj.type === 'rect_shape') { - const objectRecord = getRectShape(adapter, obj, INPAINT_MASK_LAYER_RECT_SHAPE_NAME); + let rect = this.objects.get(obj.id); + assert(rect instanceof KonvaRect || rect === undefined); - // Only update the color if it has changed. - if (objectRecord.konvaRect.fill() !== rgbColor) { - objectRecord.konvaRect.fill(rgbColor); + if (!rect) { + rect = new KonvaRect({ rectShape: obj }); + this.objects.set(rect.id, rect); + this.konvaLayer.add(rect.konvaRect); groupNeedsCache = true; } } } // Only update layer visibility if it has changed. - if (adapter.konvaLayer.visible() !== entity.isEnabled) { - adapter.konvaLayer.visible(entity.isEnabled); + if (this.konvaLayer.visible() !== inpaintMaskState.isEnabled) { + this.konvaLayer.visible(inpaintMaskState.isEnabled); groupNeedsCache = true; } - if (adapter.konvaObjectGroup.getChildren().length === 0) { + if (this.objects.size === 0) { // No objects - clear the cache to reset the previous pixel data - adapter.konvaObjectGroup.clearCache(); + this.konvaObjectGroup.clearCache(); return; } - const compositingRect = - adapter.konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(adapter.konvaLayer); - const isSelected = selectedEntity?.id === entity.id; + const isSelected = selectedEntityIdentifier?.id === inpaintMaskState.id; /** * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows @@ -169,39 +141,40 @@ export const getRenderInpaintMask = (manager: KonvaNodeManager) => { * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to * a single raster image, and _then_ applied the 50% opacity. */ - if (isSelected && toolState.selected !== 'move') { + if (isSelected && selectedTool !== 'move') { // We must clear the cache first so Konva will re-draw the group with the new compositing rect - if (adapter.konvaObjectGroup.isCached()) { - adapter.konvaObjectGroup.clearCache(); + if (this.konvaObjectGroup.isCached()) { + this.konvaObjectGroup.clearCache(); } // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work - adapter.konvaObjectGroup.opacity(1); + this.konvaObjectGroup.opacity(1); - compositingRect.setAttrs({ + this.compositingRect.setAttrs({ // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already - ...(!entity.bboxNeedsUpdate && entity.bbox ? entity.bbox : getLayerBboxFast(adapter.konvaLayer)), + ...(!inpaintMaskState.bboxNeedsUpdate && inpaintMaskState.bbox + ? inpaintMaskState.bbox + : getLayerBboxFast(this.konvaLayer)), fill: rgbColor, - opacity: globalMaskLayerOpacity, + opacity: maskOpacity, // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes) globalCompositeOperation: 'source-in', visible: true, // This rect must always be on top of all other shapes - zIndex: adapter.konvaObjectGroup.getChildren().length, + zIndex: this.objects.size + 1, }); } else { // The compositing rect should only be shown when the layer is selected. - compositingRect.visible(false); + this.compositingRect.visible(false); // Cache only if needed - or if we are on this code path and _don't_ have a cache - if (groupNeedsCache || !adapter.konvaObjectGroup.isCached()) { - adapter.konvaObjectGroup.cache(); + if (groupNeedsCache || !this.konvaObjectGroup.isCached()) { + this.konvaObjectGroup.cache(); } // Updating group opacity does not require re-caching - adapter.konvaObjectGroup.opacity(globalMaskLayerOpacity); + this.konvaObjectGroup.opacity(maskOpacity); } // const bboxRect = // regionMap.konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rg, regionMap.konvaLayer); - // if (rg.bbox) { // const active = !rg.bboxNeedsUpdate && isSelected && tool === 'move'; // bboxRect.setAttrs({ @@ -217,6 +190,4 @@ export const getRenderInpaintMask = (manager: KonvaNodeManager) => { // bboxRect.visible(false); // } } - - return renderInpaintMask; -}; +} 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 8dfb803c5b..d034078d64 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts @@ -1,156 +1,131 @@ -import { - RASTER_LAYER_BRUSH_LINE_NAME, - RASTER_LAYER_ERASER_LINE_NAME, - RASTER_LAYER_IMAGE_NAME, - RASTER_LAYER_NAME, - RASTER_LAYER_OBJECT_GROUP_NAME, - RASTER_LAYER_RECT_SHAPE_NAME, -} from 'features/controlLayers/konva/naming'; -import type { KonvaEntityAdapter, KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import { - createImageObjectGroup, - createObjectGroup, - getBrushLine, - getEraserLine, - getRectShape, -} from 'features/controlLayers/konva/renderers/objects'; +import { getObjectGroupId } from 'features/controlLayers/konva/naming'; +import type { StateApi } from 'features/controlLayers/konva/nodeManager'; +import { KonvaBrushLine, KonvaEraserLine, KonvaImage, KonvaRect } from 'features/controlLayers/konva/renderers/objects'; import { mapId } from 'features/controlLayers/konva/util'; -import type { CanvasEntity, LayerEntity, PosChangedArg, Tool } from 'features/controlLayers/store/types'; +import type { LayerEntity, Tool } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import { assert } from 'tsafe'; +import { v4 as uuidv4 } from 'uuid'; -/** - * Gets layer entity's konva nodes and entity adapter, creating them if they do not exist. - * @param manager The konva node manager - * @param entity The layer entity state - * @param onPosChanged Callback for when the layer's position changes - * @returns The konva entity adapter for the layer - */ -const getLayer = ( - manager: KonvaNodeManager, - entity: LayerEntity, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): KonvaEntityAdapter => { - const adapter = manager.get(entity.id); - if (adapter) { - return adapter; - } - // This layer hasn't been added to the konva state yet - const konvaLayer = new Konva.Layer({ - id: entity.id, - name: RASTER_LAYER_NAME, - draggable: true, - dragDistance: 0, - }); +export class KonvaLayerAdapter { + id: string; + konvaLayer: Konva.Layer; + konvaObjectGroup: Konva.Group; + objects: Map; - // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing - // the position - we do not need to call this on the `dragmove` event. - if (onPosChanged) { - konvaLayer.on('dragend', function (e) { + constructor(entity: LayerEntity, onPosChanged: StateApi['onPosChanged']) { + this.id = entity.id; + + this.konvaLayer = new Konva.Layer({ + id: entity.id, + draggable: true, + dragDistance: 0, + }); + + // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing + // the position - we do not need to call this on the `dragmove` event. + this.konvaLayer.on('dragend', function (e) { onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'layer'); }); + const konvaObjectGroup = new Konva.Group({ + id: getObjectGroupId(this.konvaLayer.id(), uuidv4()), + listening: false, + }); + this.konvaObjectGroup = konvaObjectGroup; + this.konvaLayer.add(this.konvaObjectGroup); + this.objects = new Map(); } - const konvaObjectGroup = createObjectGroup(konvaLayer, RASTER_LAYER_OBJECT_GROUP_NAME); - return manager.add(entity, konvaLayer, konvaObjectGroup); -}; - -/** - * Renders a layer. - * @param manager The konva node manager - * @param entity The layer entity state - * @param tool The current tool - * @param onPosChanged Callback for when the layer's position changes - */ -export const renderLayer = async ( - manager: KonvaNodeManager, - entity: LayerEntity, - tool: Tool, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -) => { - const adapter = getLayer(manager, entity, onPosChanged); - - // Update the layer's position and listening state - adapter.konvaLayer.setAttrs({ - listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events - x: Math.floor(entity.x), - y: Math.floor(entity.y), - }); - - const objectIds = entity.objects.map(mapId); - // Destroy any objects that are no longer in state - for (const objectRecord of adapter.getAll()) { - if (!objectIds.includes(objectRecord.id)) { - adapter.destroy(objectRecord.id); - } + destroy(): void { + this.konvaLayer.destroy(); } - for (const obj of entity.objects) { - if (obj.type === 'brush_line') { - const objectRecord = getBrushLine(adapter, obj, RASTER_LAYER_BRUSH_LINE_NAME); - // Only update the points if they have changed. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); - } - } else if (obj.type === 'eraser_line') { - const objectRecord = getEraserLine(adapter, obj, RASTER_LAYER_ERASER_LINE_NAME); - // Only update the points if they have changed. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); - } - } else if (obj.type === 'rect_shape') { - getRectShape(adapter, obj, RASTER_LAYER_RECT_SHAPE_NAME); - } else if (obj.type === 'image') { - createImageObjectGroup({ adapter, obj, name: RASTER_LAYER_IMAGE_NAME }); - } - } + async render(layerState: LayerEntity, selectedTool: Tool) { + // Update the layer's position and listening state + this.konvaLayer.setAttrs({ + listening: selectedTool === '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), + }); - // Only update layer visibility if it has changed. - if (adapter.konvaLayer.visible() !== entity.isEnabled) { - adapter.konvaLayer.visible(entity.isEnabled); - } - - // const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layerState, konvaLayer); - - // if (layerState.bbox) { - // const active = !layerState.bboxNeedsUpdate && layerState.isSelected && tool === 'move'; - // bboxRect.setAttrs({ - // visible: active, - // listening: active, - // x: layerState.bbox.x, - // y: layerState.bbox.y, - // width: layerState.bbox.width, - // height: layerState.bbox.height, - // stroke: layerState.isSelected ? BBOX_SELECTED_STROKE : '', - // strokeWidth: 1 / stage.scaleX(), - // }); - // } else { - // bboxRect.visible(false); - // } - - adapter.konvaObjectGroup.opacity(entity.opacity); -}; - -/** - * Gets a function to render all layers. - * @param manager The konva node manager - * @returns A function to render all layers - */ -export const getRenderLayers = (manager: KonvaNodeManager) => { - const { getLayersState, getToolState, onPosChanged } = manager.stateApi; - - function renderLayers(): void { - const { entities } = getLayersState(); - const tool = getToolState(); - // Destroy nonexistent layers - for (const adapter of manager.getAll('layer')) { - if (!entities.find((l) => l.id === adapter.id)) { - manager.destroy(adapter.id); + const objectIds = layerState.objects.map(mapId); + // Destroy any objects that are no longer in state + for (const object of this.objects.values()) { + if (!objectIds.includes(object.id)) { + object.destroy(); } } - for (const entity of entities) { - renderLayer(manager, entity, tool.selected, onPosChanged); - } - } - return renderLayers; -}; + for (const obj of layerState.objects) { + if (obj.type === 'brush_line') { + let brushLine = this.objects.get(obj.id); + assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); + + if (!brushLine) { + brushLine = new KonvaBrushLine({ brushLine: obj }); + this.objects.set(brushLine.id, brushLine); + this.konvaLayer.add(brushLine.konvaLineGroup); + } + if (obj.points.length !== brushLine.konvaLine.points().length) { + brushLine.konvaLine.points(obj.points); + } + } else if (obj.type === 'eraser_line') { + let eraserLine = this.objects.get(obj.id); + assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); + + if (!eraserLine) { + eraserLine = new KonvaEraserLine({ eraserLine: obj }); + this.objects.set(eraserLine.id, eraserLine); + this.konvaLayer.add(eraserLine.konvaLineGroup); + } + if (obj.points.length !== eraserLine.konvaLine.points().length) { + eraserLine.konvaLine.points(obj.points); + } + } else if (obj.type === 'rect_shape') { + let rect = this.objects.get(obj.id); + assert(rect instanceof KonvaRect || rect === undefined); + + if (!rect) { + rect = new KonvaRect({ rectShape: obj }); + this.objects.set(rect.id, rect); + this.konvaLayer.add(rect.konvaRect); + } + } else if (obj.type === 'image') { + let image = this.objects.get(obj.id); + assert(image instanceof KonvaImage || image === undefined); + + if (!image) { + image = await new KonvaImage({ imageObject: obj }); + this.objects.set(image.id, image); + this.konvaLayer.add(image.konvaImageGroup); + } + if (image.imageName !== obj.image.name) { + image.updateImageSource(obj.image.name); + } + } + } + + // Only update layer visibility if it has changed. + if (this.konvaLayer.visible() !== layerState.isEnabled) { + this.konvaLayer.visible(layerState.isEnabled); + } + + // const bboxRect = konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(layerState, konvaLayer); + // if (layerState.bbox) { + // const active = !layerState.bboxNeedsUpdate && layerState.isSelected && tool === 'move'; + // bboxRect.setAttrs({ + // visible: active, + // listening: active, + // x: layerState.bbox.x, + // y: layerState.bbox.y, + // width: layerState.bbox.width, + // height: layerState.bbox.height, + // stroke: layerState.isSelected ? BBOX_SELECTED_STROKE : '', + // strokeWidth: 1 / stage.scaleX(), + // }); + // } else { + // bboxRect.visible(false); + // } + this.konvaObjectGroup.opacity(layerState.opacity); + } +} 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 60d5038dbf..bd540b1f20 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/objects.ts @@ -1,262 +1,11 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers'; -import { - getLayerBboxId, - getObjectGroupId, - IMAGE_PLACEHOLDER_NAME, - LAYER_BBOX_NAME, - PREVIEW_GENERATION_BBOX_DUMMY_RECT, -} from 'features/controlLayers/konva/naming'; -import type { - BrushLineObjectRecord, - EraserLineObjectRecord, - ImageObjectRecord, - KonvaEntityAdapter, - RectShapeObjectRecord, -} from 'features/controlLayers/konva/nodeManager'; -import type { - BrushLine, - CanvasEntity, - EraserLine, - ImageObject, - ImageWithDims, - RectShape, -} from 'features/controlLayers/store/types'; +import { getLayerBboxId, LAYER_BBOX_NAME } from 'features/controlLayers/konva/naming'; +import type { BrushLine, CanvasEntity, EraserLine, ImageObject, RectShape } from 'features/controlLayers/store/types'; import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types'; import { t } from 'i18next'; import Konva from 'konva'; import { getImageDTO as defaultGetImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { v4 as uuidv4 } from 'uuid'; - -/** - * Utilities to create various konva objects from layer state. These are used by both the raster and regional guidance - * layers types. - */ - -/** - * Creates a konva line for a brush line. - * @param brushLine The brush line state - * @param layerObjectGroup The konva layer's object group to add the line to - * @param name The konva name for the line - */ -export const getBrushLine = ( - adapter: KonvaEntityAdapter, - brushLine: BrushLine, - name: string -): BrushLineObjectRecord => { - const objectRecord = adapter.get(brushLine.id); - if (objectRecord) { - return objectRecord; - } - const { id, strokeWidth, clip, color } = brushLine; - const konvaLineGroup = new Konva.Group({ - clip, - listening: false, - }); - const konvaLine = new Konva.Line({ - id, - name, - strokeWidth, - tension: 0, - lineCap: 'round', - lineJoin: 'round', - shadowForStrokeEnabled: false, - globalCompositeOperation: 'source-over', - listening: false, - stroke: rgbaColorToString(color), - }); - return adapter.add({ id, type: 'brush_line', konvaLine, konvaLineGroup }); -}; - -/** - * Creates a konva line for a eraser line. - * @param eraserLine The eraser line state - * @param layerObjectGroup The konva layer's object group to add the line to - * @param name The konva name for the line - */ -export const getEraserLine = ( - adapter: KonvaEntityAdapter, - eraserLine: EraserLine, - name: string -): EraserLineObjectRecord => { - const objectRecord = adapter.get(eraserLine.id); - if (objectRecord) { - return objectRecord; - } - - const { id, strokeWidth, clip } = eraserLine; - const konvaLineGroup = new Konva.Group({ - clip, - listening: false, - }); - const konvaLine = new Konva.Line({ - id, - name, - strokeWidth, - tension: 0, - lineCap: 'round', - lineJoin: 'round', - shadowForStrokeEnabled: false, - globalCompositeOperation: 'destination-out', - listening: false, - stroke: rgbaColorToString(DEFAULT_RGBA_COLOR), - }); - return adapter.add({ id, type: 'eraser_line', konvaLine, konvaLineGroup }); -}; - -/** - * Creates a konva rect for a rect shape. - * @param rectShape The rect shape state - * @param layerObjectGroup The konva layer's object group to add the rect to - * @param name The konva name for the rect - */ -export const getRectShape = ( - adapter: KonvaEntityAdapter, - rectShape: RectShape, - name: string -): RectShapeObjectRecord => { - const objectRecord = adapter.get(rectShape.id); - if (objectRecord) { - return objectRecord; - } - const { id, x, y, width, height } = rectShape; - const konvaRect = new Konva.Rect({ - id, - name, - x, - y, - width, - height, - listening: false, - fill: rgbaColorToString(rectShape.color), - }); - return adapter.add({ id: rectShape.id, type: 'rect_shape', konvaRect }); -}; - -export const updateImageSource = async (arg: { - objectRecord: ImageObjectRecord; - image: ImageWithDims; - getImageDTO?: (imageName: string) => Promise; - onLoading?: () => void; - onLoad?: (konvaImage: Konva.Image) => void; - onError?: () => void; -}) => { - const { objectRecord, image, getImageDTO = defaultGetImageDTO, onLoading, onLoad, onError } = arg; - - try { - objectRecord.isLoading = true; - if (!objectRecord.konvaImage) { - objectRecord.konvaPlaceholderGroup.visible(true); - objectRecord.konvaPlaceholderText.text(t('common.loadingImage', 'Loading Image')); - } - onLoading?.(); - - const imageDTO = await getImageDTO(image.name); - if (!imageDTO) { - objectRecord.imageName = null; - objectRecord.isLoading = false; - objectRecord.isError = true; - objectRecord.konvaPlaceholderGroup.visible(true); - objectRecord.konvaPlaceholderText.text(t('common.imageFailedToLoad', 'Image Failed to Load')); - onError?.(); - return; - } - const imageEl = new Image(); - imageEl.onload = () => { - if (objectRecord.konvaImage) { - objectRecord.konvaImage.setAttrs({ - image: imageEl, - }); - } else { - objectRecord.konvaImage = new Konva.Image({ - id: objectRecord.id, - listening: false, - image: imageEl, - }); - objectRecord.konvaImageGroup.add(objectRecord.konvaImage); - objectRecord.imageName = image.name; - } - objectRecord.isLoading = false; - objectRecord.isError = false; - objectRecord.konvaPlaceholderGroup.visible(false); - onLoad?.(objectRecord.konvaImage); - }; - imageEl.onerror = () => { - objectRecord.imageName = null; - objectRecord.isLoading = false; - objectRecord.isError = true; - objectRecord.konvaPlaceholderGroup.visible(true); - objectRecord.konvaPlaceholderText.text(t('common.imageFailedToLoad', 'Image Failed to Load')); - onError?.(); - }; - imageEl.id = image.name; - imageEl.src = imageDTO.image_url; - } catch { - objectRecord.imageName = null; - objectRecord.isLoading = false; - objectRecord.isError = true; - objectRecord.konvaPlaceholderGroup.visible(true); - objectRecord.konvaPlaceholderText.text(t('common.imageFailedToLoad', 'Image Failed to Load')); - onError?.(); - } -}; - -/** - * Creates an image placeholder group for an image object. - * @param image The image object state - * @returns The konva group for the image placeholder, and callbacks to handle loading and error states - */ -export const createImageObjectGroup = (arg: { - adapter: KonvaEntityAdapter; - obj: ImageObject; - name: string; - getImageDTO?: (imageName: string) => Promise; - onLoad?: (konvaImage: Konva.Image) => void; - onLoading?: () => void; - onError?: () => void; -}): ImageObjectRecord => { - const { adapter, obj, name, getImageDTO = defaultGetImageDTO, onLoad, onLoading, onError } = arg; - let objectRecord = adapter.get(obj.id); - if (objectRecord) { - return objectRecord; - } - const { id, image } = obj; - const { width, height } = obj; - const konvaImageGroup = new Konva.Group({ id, name, listening: false, x: obj.x, y: obj.y }); - const konvaPlaceholderGroup = new Konva.Group({ name: IMAGE_PLACEHOLDER_NAME, listening: false }); - const konvaPlaceholderRect = new Konva.Rect({ - fill: 'hsl(220 12% 45% / 1)', // 'base.500' - width, - height, - listening: false, - }); - const konvaPlaceholderText = new Konva.Text({ - fill: 'hsl(220 12% 10% / 1)', // 'base.900' - width, - height, - align: 'center', - verticalAlign: 'middle', - fontFamily: '"Inter Variable", sans-serif', - fontSize: width / 16, - fontStyle: '600', - text: t('common.loadingImage', 'Loading Image'), - listening: false, - }); - objectRecord = adapter.add({ - id, - type: 'image', - konvaImageGroup, - konvaPlaceholderGroup, - konvaPlaceholderRect, - konvaPlaceholderText, - konvaImage: null, - imageName: null, - isLoading: false, - isError: false, - }); - updateImageSource({ objectRecord, image, getImageDTO, onLoad, onLoading, onError }); - return objectRecord; -}; /** * Creates a bounding box rect for a layer. @@ -274,33 +23,217 @@ export const createBboxRect = (entity: CanvasEntity, konvaLayer: Konva.Layer): K return rect; }; -/** - * Creates a konva group for a layer's objects. - * @param konvaLayer The konva layer to add the object group to - * @param name The konva name for the group - * @returns - */ -export const createObjectGroup = (konvaLayer: Konva.Layer, name: string): Konva.Group => { - const konvaObjectGroup = new Konva.Group({ - id: getObjectGroupId(konvaLayer.id(), uuidv4()), - name, - listening: false, - }); - konvaLayer.add(konvaObjectGroup); - return konvaObjectGroup; -}; +export class KonvaBrushLine { + id: string; + konvaLineGroup: Konva.Group; + konvaLine: Konva.Line; -export const createImageDimsPreview = (konvaLayer: Konva.Layer, width: number, height: number): Konva.Rect => { - const imageDimsPreview = new Konva.Rect({ - id: PREVIEW_GENERATION_BBOX_DUMMY_RECT, - x: 0, - y: 0, - width, - height, - stroke: 'rgb(255,0,255)', - strokeWidth: 1 / konvaLayer.getStage().scaleX(), - listening: false, - }); - konvaLayer.add(imageDimsPreview); - return imageDimsPreview; -}; + constructor(arg: { brushLine: BrushLine }) { + const { brushLine } = arg; + const { id, strokeWidth, clip, color } = brushLine; + this.id = id; + this.konvaLineGroup = new Konva.Group({ + clip, + listening: false, + }); + this.konvaLine = new Konva.Line({ + id, + listening: false, + shadowForStrokeEnabled: false, + strokeWidth, + tension: 0, + lineCap: 'round', + lineJoin: 'round', + globalCompositeOperation: 'source-over', + stroke: rgbaColorToString(color), + }); + this.konvaLineGroup.add(this.konvaLine); + } + + destroy() { + this.konvaLineGroup.destroy(); + } +} + +export class KonvaEraserLine { + id: string; + konvaLineGroup: Konva.Group; + konvaLine: Konva.Line; + + constructor(arg: { eraserLine: EraserLine }) { + const { eraserLine } = arg; + const { id, strokeWidth, clip } = eraserLine; + this.id = id; + this.konvaLineGroup = new Konva.Group({ + clip, + listening: false, + }); + this.konvaLine = new Konva.Line({ + id, + listening: false, + shadowForStrokeEnabled: false, + strokeWidth, + tension: 0, + lineCap: 'round', + lineJoin: 'round', + globalCompositeOperation: 'destination-out', + stroke: rgbaColorToString(DEFAULT_RGBA_COLOR), + }); + this.konvaLineGroup.add(this.konvaLine); + } + + destroy() { + this.konvaLineGroup.destroy(); + } +} + +export class KonvaRect { + id: string; + konvaRect: Konva.Rect; + + constructor(arg: { rectShape: RectShape }) { + const { rectShape } = arg; + const { id, x, y, width, height } = rectShape; + this.id = id; + const konvaRect = new Konva.Rect({ + id, + x, + y, + width, + height, + listening: false, + fill: rgbaColorToString(rectShape.color), + }); + this.konvaRect = konvaRect; + } + + destroy() { + this.konvaRect.destroy(); + } +} + +export class KonvaImage { + id: string; + konvaImageGroup: Konva.Group; + konvaPlaceholderGroup: Konva.Group; + konvaPlaceholderRect: Konva.Rect; + konvaPlaceholderText: Konva.Text; + imageName: string | null; + konvaImage: Konva.Image | null; // The image is loaded asynchronously, so it may not be available immediately + isLoading: boolean; + isError: boolean; + getImageDTO: (imageName: string) => Promise; + onLoading: () => void; + onLoad: (imageName: string, imageEl: HTMLImageElement) => void; + onError: () => void; + + constructor(arg: { + imageObject: ImageObject; + getImageDTO?: (imageName: string) => Promise; + onLoading?: () => void; + onLoad?: (konvaImage: Konva.Image) => void; + onError?: () => void; + }) { + const { imageObject, getImageDTO, onLoading, onLoad, onError } = arg; + const { id, width, height, x, y } = imageObject; + this.konvaImageGroup = new Konva.Group({ id, listening: false, x, y }); + this.konvaPlaceholderGroup = new Konva.Group({ listening: false }); + this.konvaPlaceholderRect = new Konva.Rect({ + fill: 'hsl(220 12% 45% / 1)', // 'base.500' + width, + height, + listening: false, + }); + this.konvaPlaceholderText = new Konva.Text({ + fill: 'hsl(220 12% 10% / 1)', // 'base.900' + width, + height, + align: 'center', + verticalAlign: 'middle', + fontFamily: '"Inter Variable", sans-serif', + fontSize: width / 16, + fontStyle: '600', + text: t('common.loadingImage', 'Loading Image'), + listening: false, + }); + + this.konvaPlaceholderGroup.add(this.konvaPlaceholderRect); + this.konvaPlaceholderGroup.add(this.konvaPlaceholderText); + this.konvaImageGroup.add(this.konvaPlaceholderGroup); + + this.id = id; + this.imageName = null; + this.konvaImage = null; + this.isLoading = false; + this.isError = false; + this.getImageDTO = getImageDTO ?? defaultGetImageDTO; + this.onLoading = function () { + this.isLoading = true; + if (!this.konvaImage) { + this.konvaPlaceholderGroup.visible(true); + this.konvaPlaceholderText.text(t('common.loadingImage', 'Loading Image')); + } + if (onLoading) { + onLoading(); + } + }; + this.onLoad = function (imageName: string, imageEl: HTMLImageElement) { + if (this.konvaImage) { + this.konvaImage.setAttrs({ + image: imageEl, + }); + } else { + this.konvaImage = new Konva.Image({ + id: this.id, + listening: false, + image: imageEl, + }); + this.konvaImageGroup.add(this.konvaImage); + this.imageName = imageName; + } + this.isLoading = false; + this.isError = false; + this.konvaPlaceholderGroup.visible(false); + if (onLoad) { + onLoad(this.konvaImage); + } + }; + this.onError = function () { + this.imageName = null; + this.isLoading = false; + this.isError = true; + this.konvaPlaceholderGroup.visible(true); + this.konvaPlaceholderText.text(t('common.imageFailedToLoad', 'Image Failed to Load')); + if (onError) { + onError(); + } + }; + } + + async updateImageSource(imageName: string) { + try { + this.onLoading(); + + const imageDTO = await this.getImageDTO(imageName); + if (!imageDTO) { + this.onError(); + return; + } + const imageEl = new Image(); + imageEl.onload = () => { + this.onLoad(imageName, imageEl); + }; + imageEl.onerror = () => { + this.onError(); + }; + imageEl.id = imageName; + imageEl.src = imageDTO.image_url; + } catch { + this.onError(); + } + } + + destroy() { + this.konvaImageGroup.destroy(); + } +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts index b5dcfb1df4..5605b4f40a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts @@ -19,10 +19,13 @@ import { PREVIEW_TOOL_GROUP_ID, } from 'features/controlLayers/konva/naming'; import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import type { CanvasV2State } from 'features/controlLayers/store/types'; +import { createImageObjectGroup, updateImageSource } from 'features/controlLayers/konva/renderers/objects'; +import type { CanvasEntity, CanvasV2State, Position, RgbaColor } from 'features/controlLayers/store/types'; +import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types'; import Konva from 'konva'; import type { IRect } from 'konva/lib/types'; import { atom } from 'nanostores'; +import { assert } from 'tsafe'; /** * Creates the konva preview layer. @@ -511,3 +514,230 @@ export const getRenderDocumentOverlay = (manager: KonvaNodeManager) => { return renderDocumentOverlay; }; + +export const createStagingArea = (): KonvaNodeManager['preview']['stagingArea'] => { + const group = new Konva.Group({ id: 'staging_area_group', listening: false }); + return { group, image: null }; +}; + +export const getRenderStagingArea = async (manager: KonvaNodeManager) => { + const { getStagingAreaState } = manager.stateApi; + const stagingArea = getStagingAreaState(); + + if (!stagingArea || stagingArea.selectedImageIndex === null) { + if (manager.preview.stagingArea.image) { + manager.preview.stagingArea.image.konvaImageGroup.visible(false); + manager.preview.stagingArea.image = null; + } + return; + } + + if (stagingArea.selectedImageIndex) { + const imageDTO = stagingArea.images[stagingArea.selectedImageIndex]; + assert(imageDTO, 'Image must exist'); + if (manager.preview.stagingArea.image) { + if (manager.preview.stagingArea.image.imageName !== imageDTO.image_name) { + await updateImageSource({ + objectRecord: manager.preview.stagingArea.image, + image: imageDTOToImageWithDims(imageDTO), + }); + } + } else { + manager.preview.stagingArea.image = await createImageObjectGroup({ + obj: imageDTOToImageObject(imageDTO), + name: imageDTO.image_name, + }); + } + } +}; + +export class KonvaPreview { + konvaLayer: Konva.Layer; + bbox: { + group: Konva.Group; + rect: Konva.Rect; + transformer: Konva.Transformer; + }; + tool: { + group: Konva.Group; + brush: { + group: Konva.Group; + fill: Konva.Circle; + innerBorder: Konva.Circle; + outerBorder: Konva.Circle; + }; + rect: { + rect: Konva.Rect; + }; + }; + documentOverlay: { + group: Konva.Group; + innerRect: Konva.Rect; + outerRect: Konva.Rect; + }; + stagingArea: { + group: Konva.Group; + // image: KonvaImage | null; + }; + + constructor( + stage: Konva.Stage, + getBbox: () => IRect, + onBboxTransformed: (bbox: IRect) => void, + getShiftKey: () => boolean, + getCtrlKey: () => boolean, + getMetaKey: () => boolean, + getAltKey: () => boolean + ) { + this.konvaLayer = createPreviewLayer(); + this.bbox = createBboxNodes(stage, getBbox, onBboxTransformed, getShiftKey, getCtrlKey, getMetaKey, getAltKey); + this.tool = createToolPreviewNodes(); + this.documentOverlay = createDocumentOverlay(); + this.stagingArea = createStagingArea(); + } + + renderBbox(bbox: CanvasV2State['bbox'], toolState: CanvasV2State['tool']) { + this.bbox.group.listening(toolState.selected === 'bbox'); + // This updates the bbox during transformation + this.bbox.rect.setAttrs({ + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, + scaleX: 1, + scaleY: 1, + listening: toolState.selected === 'bbox', + }); + this.bbox.transformer.setAttrs({ + listening: toolState.selected === 'bbox', + enabledAnchors: toolState.selected === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, + }); + } + + scaleToolPreview(stage: Konva.Stage, toolState: CanvasV2State['tool']) { + const scale = stage.scaleX(); + const radius = (toolState.selected === 'brush' ? toolState.brush.width : toolState.eraser.width) / 2; + this.tool.brush.innerBorder.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale); + this.tool.brush.outerBorder.setAttrs({ + strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, + radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, + }); + } + + renderToolPreview( + stage: Konva.Stage, + renderedEntityCount: number, + toolState: CanvasV2State['tool'], + currentFill: RgbaColor, + selectedEntity: CanvasEntity | null, + cursorPos: Position | null, + lastMouseDownPos: Position | null, + isDrawing: boolean, + isMouseDown: boolean + ) { + const tool = toolState.selected; + const isDrawableEntity = + selectedEntity?.type === 'regional_guidance' || + selectedEntity?.type === 'layer' || + selectedEntity?.type === 'inpaint_mask'; + + // Update the stage's pointer style + if (tool === 'view') { + // View gets a hand + stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab'; + } else if (renderedEntityCount === 0) { + // We have no layers, so we should not render any tool + stage.container().style.cursor = 'default'; + } else if (!isDrawableEntity) { + // Non-drawable 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 if (tool === 'brush' || tool === 'eraser') { + // Hide the native cursor and use the konva-rendered brush preview + stage.container().style.cursor = 'none'; + } else if (tool === 'bbox') { + stage.container().style.cursor = 'default'; + } + + stage.draggable(tool === 'view'); + + if (!cursorPos || renderedEntityCount === 0 || !isDrawableEntity) { + // We can bail early if the mouse isn't over the stage or there are no layers + this.tool.group.visible(false); + } else { + this.tool.group.visible(true); + + // No need to render the brush preview if the cursor position or color is missing + if (cursorPos && (tool === 'brush' || tool === 'eraser')) { + const scale = stage.scaleX(); + // Update the fill circle + const radius = (tool === 'brush' ? toolState.brush.width : toolState.eraser.width) / 2; + this.tool.brush.fill.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius, + fill: isDrawing ? '' : rgbaColorToString(currentFill), + globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', + }); + + // Update the inner border of the brush preview + this.tool.brush.innerBorder.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius }); + + // Update the outer border of the brush preview + this.tool.brush.outerBorder.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, + }); + + this.scaleToolPreview(stage, toolState); + + this.tool.brush.group.visible(true); + } else { + this.tool.brush.group.visible(false); + } + + if (cursorPos && lastMouseDownPos && tool === 'rect') { + this.tool.rect.rect.setAttrs({ + x: Math.min(cursorPos.x, lastMouseDownPos.x), + y: Math.min(cursorPos.y, lastMouseDownPos.y), + width: Math.abs(cursorPos.x - lastMouseDownPos.x), + height: Math.abs(cursorPos.y - lastMouseDownPos.y), + fill: rgbaColorToString(currentFill), + visible: true, + }); + } else { + this.tool.rect.rect.visible(false); + } + } + } + + renderDocumentOverlay(stage: Konva.Stage, document: CanvasV2State['document']) { + this.documentOverlay.group.zIndex(0); + + const x = stage.x(); + const y = stage.y(); + const width = stage.width(); + const height = stage.height(); + const scale = stage.scaleX(); + + this.documentOverlay.outerRect.setAttrs({ + offsetX: x / scale, + offsetY: y / scale, + width: width / scale, + height: height / scale, + }); + + this.documentOverlay.innerRect.setAttrs({ + x: 0, + y: 0, + width: document.width, + height: document.height, + }); + } +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts index 70b8cfb51b..a7263df9f4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts @@ -1,257 +1,191 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers'; -import { - COMPOSITING_RECT_NAME, - RG_LAYER_BRUSH_LINE_NAME, - RG_LAYER_ERASER_LINE_NAME, - RG_LAYER_NAME, - RG_LAYER_OBJECT_GROUP_NAME, - RG_LAYER_RECT_SHAPE_NAME, -} from 'features/controlLayers/konva/naming'; -import type { KonvaEntityAdapter, KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; +import { getObjectGroupId } from 'features/controlLayers/konva/naming'; +import type { StateApi } from 'features/controlLayers/konva/nodeManager'; import { getLayerBboxFast } from 'features/controlLayers/konva/renderers/bbox'; -import { - createObjectGroup, - getBrushLine, - getEraserLine, - getRectShape, -} from 'features/controlLayers/konva/renderers/objects'; +import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects'; import { mapId } from 'features/controlLayers/konva/util'; -import type { - CanvasEntity, - CanvasEntityIdentifier, - PosChangedArg, - RegionEntity, - Tool, -} from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, RegionEntity, Tool } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import { assert } from 'tsafe'; +import { v4 as uuidv4 } from 'uuid'; -/** - * Creates the "compositing rect" for a regional guidance layer. - * @param konvaLayer The konva layer - */ -const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { - const compositingRect = new Konva.Rect({ name: COMPOSITING_RECT_NAME, listening: false }); - konvaLayer.add(compositingRect); - return compositingRect; -}; +export class KonvaRegion { + id: string; + konvaLayer: Konva.Layer; + konvaObjectGroup: Konva.Group; + compositingRect: Konva.Rect; + objects: Map; -/** - * Gets a region's konva nodes and entity adapter, creating them if they do not exist. - * @param stage The konva stage - * @param entity The regional guidance layer state - * @param onLayerPosChanged Callback for when the layer's position changes - * @returns The konva entity adapter for the region - */ -const getRegion = ( - manager: KonvaNodeManager, - entity: RegionEntity, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): KonvaEntityAdapter => { - const adapter = manager.get(entity.id); - if (adapter) { - return adapter; - } - // This layer hasn't been added to the konva state yet - const konvaLayer = new Konva.Layer({ - id: entity.id, - name: RG_LAYER_NAME, - draggable: true, - dragDistance: 0, - }); + constructor(entity: RegionEntity, onPosChanged: StateApi['onPosChanged']) { + this.id = entity.id; - // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing - // the position - we do not need to call this on the `dragmove` event. - if (onPosChanged) { - konvaLayer.on('dragend', function (e) { + this.konvaLayer = new Konva.Layer({ + id: entity.id, + draggable: true, + dragDistance: 0, + }); + + // When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing + // the position - we do not need to call this on the `dragmove` event. + this.konvaLayer.on('dragend', function (e) { onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'regional_guidance'); }); + this.konvaObjectGroup = new Konva.Group({ + id: getObjectGroupId(this.konvaLayer.id(), uuidv4()), + listening: false, + }); + this.konvaLayer.add(this.konvaObjectGroup); + this.compositingRect = new Konva.Rect({ listening: false }); + this.konvaLayer.add(this.compositingRect); + this.objects = new Map(); } - const konvaObjectGroup = createObjectGroup(konvaLayer, RG_LAYER_OBJECT_GROUP_NAME); - return manager.add(entity, konvaLayer, konvaObjectGroup); -}; + destroy(): void { + this.konvaLayer.destroy(); + } -/** - * Renders a region. - * @param stage The konva stage - * @param entity The regional guidance layer state - * @param globalMaskLayerOpacity The global mask layer opacity - * @param tool The current tool - * @param onPosChanged Callback for when the layer's position changes - */ -export const renderRegion = ( - manager: KonvaNodeManager, - entity: RegionEntity, - globalMaskLayerOpacity: number, - tool: Tool, - selectedEntityIdentifier: CanvasEntityIdentifier | null, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): void => { - const adapter = getRegion(manager, entity, onPosChanged); + async render( + regionState: RegionEntity, + selectedTool: Tool, + selectedEntityIdentifier: CanvasEntityIdentifier | null, + maskOpacity: number + ) { + // Update the layer's position and listening state + this.konvaLayer.setAttrs({ + listening: selectedTool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events + x: Math.floor(regionState.x), + y: Math.floor(regionState.y), + }); - // Update the layer's position and listening state - adapter.konvaLayer.setAttrs({ - listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events - x: Math.floor(entity.x), - y: Math.floor(entity.y), - }); + // Convert the color to a string, stripping the alpha - the object group will handle opacity. + const rgbColor = rgbColorToString(regionState.fill); - // Convert the color to a string, stripping the alpha - the object group will handle opacity. - const rgbColor = rgbColorToString(entity.fill); + // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. + let groupNeedsCache = false; - // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. - let groupNeedsCache = false; + const objectIds = regionState.objects.map(mapId); + // Destroy any objects that are no longer in state + for (const object of this.objects.values()) { + if (!objectIds.includes(object.id)) { + object.destroy(); + groupNeedsCache = true; + } + } - const objectIds = entity.objects.map(mapId); - // Destroy any objects that are no longer in state - for (const objectRecord of adapter.getAll()) { - if (!objectIds.includes(objectRecord.id)) { - adapter.destroy(objectRecord.id); + for (const obj of regionState.objects) { + if (obj.type === 'brush_line') { + let brushLine = this.objects.get(obj.id); + assert(brushLine instanceof KonvaBrushLine || brushLine === undefined); + + if (!brushLine) { + brushLine = new KonvaBrushLine({ brushLine: obj }); + this.objects.set(brushLine.id, brushLine); + this.konvaLayer.add(brushLine.konvaLineGroup); + groupNeedsCache = true; + } + + if (obj.points.length !== brushLine.konvaLine.points().length) { + brushLine.konvaLine.points(obj.points); + groupNeedsCache = true; + } + } else if (obj.type === 'eraser_line') { + let eraserLine = this.objects.get(obj.id); + assert(eraserLine instanceof KonvaEraserLine || eraserLine === undefined); + + if (!eraserLine) { + eraserLine = new KonvaEraserLine({ eraserLine: obj }); + this.objects.set(eraserLine.id, eraserLine); + this.konvaLayer.add(eraserLine.konvaLineGroup); + groupNeedsCache = true; + } + + if (obj.points.length !== eraserLine.konvaLine.points().length) { + eraserLine.konvaLine.points(obj.points); + groupNeedsCache = true; + } + } else if (obj.type === 'rect_shape') { + let rect = this.objects.get(obj.id); + assert(rect instanceof KonvaRect || rect === undefined); + + if (!rect) { + rect = new KonvaRect({ rectShape: obj }); + this.objects.set(rect.id, rect); + this.konvaLayer.add(rect.konvaRect); + groupNeedsCache = true; + } + } + } + + // Only update layer visibility if it has changed. + if (this.konvaLayer.visible() !== regionState.isEnabled) { + this.konvaLayer.visible(regionState.isEnabled); groupNeedsCache = true; } - } - for (const obj of entity.objects) { - if (obj.type === 'brush_line') { - const objectRecord = getBrushLine(adapter, obj, RG_LAYER_BRUSH_LINE_NAME); - - // Only update the points if they have changed. The point values are never mutated, they are only added to the - // array, so checking the length is sufficient to determine if we need to re-cache. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); - groupNeedsCache = true; - } - // Only update the color if it has changed. - if (objectRecord.konvaLine.stroke() !== rgbColor) { - objectRecord.konvaLine.stroke(rgbColor); - groupNeedsCache = true; - } - } else if (obj.type === 'eraser_line') { - const objectRecord = getEraserLine(adapter, obj, RG_LAYER_ERASER_LINE_NAME); - - // Only update the points if they have changed. The point values are never mutated, they are only added to the - // array, so checking the length is sufficient to determine if we need to re-cache. - if (objectRecord.konvaLine.points().length !== obj.points.length) { - objectRecord.konvaLine.points(obj.points); - groupNeedsCache = true; - } - // Only update the color if it has changed. - if (objectRecord.konvaLine.stroke() !== rgbColor) { - objectRecord.konvaLine.stroke(rgbColor); - groupNeedsCache = true; - } - } else if (obj.type === 'rect_shape') { - const objectRecord = getRectShape(adapter, obj, RG_LAYER_RECT_SHAPE_NAME); - - // Only update the color if it has changed. - if (objectRecord.konvaRect.fill() !== rgbColor) { - objectRecord.konvaRect.fill(rgbColor); - groupNeedsCache = true; - } - } - } - - // Only update layer visibility if it has changed. - if (adapter.konvaLayer.visible() !== entity.isEnabled) { - adapter.konvaLayer.visible(entity.isEnabled); - groupNeedsCache = true; - } - - if (adapter.konvaObjectGroup.getChildren().length === 0) { - // No objects - clear the cache to reset the previous pixel data - adapter.konvaObjectGroup.clearCache(); - return; - } - - const compositingRect = - adapter.konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(adapter.konvaLayer); - const isSelected = selectedEntityIdentifier?.id === entity.id; - - /** - * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows - * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity. - * - * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The - * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity. - * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes. - * - * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to - * a single raster image, and _then_ applied the 50% opacity. - */ - if (isSelected && tool !== 'move') { - // We must clear the cache first so Konva will re-draw the group with the new compositing rect - if (adapter.konvaObjectGroup.isCached()) { - adapter.konvaObjectGroup.clearCache(); - } - // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work - adapter.konvaObjectGroup.opacity(1); - - compositingRect.setAttrs({ - // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already - ...(!entity.bboxNeedsUpdate && entity.bbox ? entity.bbox : getLayerBboxFast(adapter.konvaLayer)), - fill: rgbColor, - opacity: globalMaskLayerOpacity, - // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes) - globalCompositeOperation: 'source-in', - visible: true, - // This rect must always be on top of all other shapes - zIndex: adapter.konvaObjectGroup.getChildren().length, - }); - } else { - // The compositing rect should only be shown when the layer is selected. - compositingRect.visible(false); - // Cache only if needed - or if we are on this code path and _don't_ have a cache - if (groupNeedsCache || !adapter.konvaObjectGroup.isCached()) { - adapter.konvaObjectGroup.cache(); - } - // Updating group opacity does not require re-caching - adapter.konvaObjectGroup.opacity(globalMaskLayerOpacity); - } - - // const bboxRect = - // regionMap.konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rg, regionMap.konvaLayer); - - // if (rg.bbox) { - // const active = !rg.bboxNeedsUpdate && isSelected && tool === 'move'; - // bboxRect.setAttrs({ - // visible: active, - // listening: active, - // x: rg.bbox.x, - // y: rg.bbox.y, - // width: rg.bbox.width, - // height: rg.bbox.height, - // stroke: isSelected ? BBOX_SELECTED_STROKE : '', - // }); - // } else { - // bboxRect.visible(false); - // } -}; - -/** - * Gets a function to render all regions. - * @param manager The konva node manager - * @returns A function to render all regions - */ -export const getRenderRegions = (manager: KonvaNodeManager) => { - const { getRegionsState, getMaskOpacity, getToolState, getSelectedEntity, onPosChanged } = manager.stateApi; - - function renderRegions(): void { - const { entities } = getRegionsState(); - const maskOpacity = getMaskOpacity(); - const toolState = getToolState(); - const selectedEntity = getSelectedEntity(); - - // Destroy the konva nodes for nonexistent entities - for (const adapter of manager.getAll('regional_guidance')) { - if (!entities.find((rg) => rg.id === adapter.id)) { - manager.destroy(adapter.id); - } + if (this.objects.size === 0) { + // No objects - clear the cache to reset the previous pixel data + this.konvaObjectGroup.clearCache(); + return; } - for (const entity of entities) { - renderRegion(manager, entity, maskOpacity, toolState.selected, selectedEntity, onPosChanged); - } - } + const isSelected = selectedEntityIdentifier?.id === regionState.id; - return renderRegions; -}; + /** + * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows + * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity. + * + * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The + * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity. + * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes. + * + * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to + * a single raster image, and _then_ applied the 50% opacity. + */ + if (isSelected && selectedTool !== 'move') { + // We must clear the cache first so Konva will re-draw the group with the new compositing rect + if (this.konvaObjectGroup.isCached()) { + this.konvaObjectGroup.clearCache(); + } + // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work + this.konvaObjectGroup.opacity(1); + + this.compositingRect.setAttrs({ + // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already + ...(!regionState.bboxNeedsUpdate && regionState.bbox ? regionState.bbox : getLayerBboxFast(this.konvaLayer)), + fill: rgbColor, + opacity: maskOpacity, + // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes) + globalCompositeOperation: 'source-in', + visible: true, + // This rect must always be on top of all other shapes + zIndex: this.objects.size + 1, + }); + } else { + // The compositing rect should only be shown when the layer is selected. + this.compositingRect.visible(false); + // Cache only if needed - or if we are on this code path and _don't_ have a cache + if (groupNeedsCache || !this.konvaObjectGroup.isCached()) { + this.konvaObjectGroup.cache(); + } + // Updating group opacity does not require re-caching + this.konvaObjectGroup.opacity(maskOpacity); + } + + // const bboxRect = + // regionMap.konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rg, regionMap.konvaLayer); + // if (rg.bbox) { + // const active = !rg.bboxNeedsUpdate && isSelected && tool === 'move'; + // bboxRect.setAttrs({ + // visible: active, + // listening: active, + // x: rg.bbox.x, + // y: rg.bbox.y, + // width: rg.bbox.width, + // height: rg.bbox.height, + // stroke: isSelected ? BBOX_SELECTED_STROKE : '', + // }); + // } else { + // bboxRect.visible(false); + // } + } +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts index 890c93a580..dad2e4a51c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts @@ -5,24 +5,9 @@ import { $isDebugging } from 'app/store/nanostores/isDebugging'; import type { RootState } from 'app/store/store'; import { setStageEventHandlers } from 'features/controlLayers/konva/events'; import { KonvaNodeManager, setNodeManager } from 'features/controlLayers/konva/nodeManager'; -import { getArrangeEntities } from 'features/controlLayers/konva/renderers/arrange'; -import { createBackgroundLayer, getRenderBackground } from 'features/controlLayers/konva/renderers/background'; +import { KonvaBackground } from 'features/controlLayers/konva/renderers/background'; import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox'; -import { getRenderControlAdapters } from 'features/controlLayers/konva/renderers/controlAdapters'; -import { getRenderInpaintMask } from 'features/controlLayers/konva/renderers/inpaintMask'; -import { getRenderLayers } from 'features/controlLayers/konva/renderers/layers'; -import { - createBboxNodes, - createDocumentOverlay, - createPreviewLayer, - createToolPreviewNodes, - getRenderBbox, - getRenderDocumentOverlay, - getRenderToolPreview, -} from 'features/controlLayers/konva/renderers/preview'; -import { getRenderRegions } from 'features/controlLayers/konva/renderers/regions'; -import { getFitDocumentToStage, getFitStageToContainer } from 'features/controlLayers/konva/renderers/stage'; -import { createStagingArea, getRenderStagingArea } from 'features/controlLayers/konva/renderers/stagingArea'; +import { KonvaPreview } from 'features/controlLayers/konva/renderers/preview'; import { $stageAttrs, bboxChanged, @@ -299,23 +284,7 @@ export const initializeRenderer = ( spaceKey = val; }; - const manager = new KonvaNodeManager(stage, container); - setNodeManager(manager); - - manager.background = { layer: createBackgroundLayer() }; - manager.stage.add(manager.background.layer); - manager.preview = { - layer: createPreviewLayer(), - bbox: createBboxNodes(stage, getBbox, onBboxTransformed, $shift.get, $ctrl.get, $meta.get, $alt.get), - tool: createToolPreviewNodes(), - documentOverlay: createDocumentOverlay(), - stagingArea: createStagingArea(), - }; - manager.preview.layer.add(manager.preview.bbox.group); - manager.preview.layer.add(manager.preview.tool.group); - manager.preview.layer.add(manager.preview.documentOverlay.group); - manager.stage.add(manager.preview.layer); - manager.stateApi = { + const stateApi: KonvaNodeManager['stateApi'] = { // Read-only state getToolState, getSelectedEntity, @@ -365,6 +334,26 @@ export const initializeRenderer = ( onLayerImageCached, }; + const manager = new KonvaNodeManager(stage, container, stateApi); + setNodeManager(manager); + console.log(manager); + + manager.background = new KonvaBackground(); + manager.stage.add(manager.background.konvaLayer); + manager.preview = new KonvaPreview({ + stage, + getBbox, + onBboxTransformed, + getShiftKey: $shift.get, + getCtrlKey: $ctrl.get, + getMetaKey: $meta.get, + getAltKey: $alt.get, + }); + manager.preview.konvaLayer.add(manager.preview.bbox.group); + manager.preview.konvaLayer.add(manager.preview.tool.group); + manager.preview.konvaLayer.add(manager.preview.documentOverlay.group); + manager.stage.add(manager.preview.konvaLayer); + const cleanupListeners = setStageEventHandlers(manager); // Calculating bounding boxes is expensive, must be debounced to not block the UI thread during a user interaction. @@ -372,21 +361,6 @@ export const initializeRenderer = ( // the entire state over when needed. const debouncedUpdateBboxes = debounce(updateBboxes, 300); - manager.konvaApi = { - renderRegions: getRenderRegions(manager), - renderLayers: getRenderLayers(manager), - renderControlAdapters: getRenderControlAdapters(manager), - renderInpaintMask: getRenderInpaintMask(manager), - renderBbox: getRenderBbox(manager), - renderToolPreview: getRenderToolPreview(manager), - renderDocumentOverlay: getRenderDocumentOverlay(manager), - renderStagingArea: getRenderStagingArea(manager), - renderBackground: getRenderBackground(manager), - arrangeEntities: getArrangeEntities(manager), - fitDocumentToStage: getFitDocumentToStage(manager), - fitStageToContainer: getFitStageToContainer(manager), - }; - const renderCanvas = () => { canvasV2 = store.getState().canvasV2; @@ -404,7 +378,7 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering layers'); - manager.konvaApi.renderLayers(); + manager.renderLayers(); } if ( @@ -414,7 +388,7 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering regions'); - manager.konvaApi.renderRegions(); + manager.renderRegions(); } if ( @@ -424,22 +398,22 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering inpaint mask'); - manager.konvaApi.renderInpaintMask(); + manager.renderInpaintMask(); } if (isFirstRender || canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities) { logIfDebugging('Rendering control adapters'); - manager.konvaApi.renderControlAdapters(); + manager.renderControlAdapters(); } if (isFirstRender || canvasV2.document !== prevCanvasV2.document) { logIfDebugging('Rendering document bounds overlay'); - manager.konvaApi.renderDocumentOverlay(); + manager.renderDocumentOverlay(); } if (isFirstRender || canvasV2.bbox !== prevCanvasV2.bbox || canvasV2.tool.selected !== prevCanvasV2.tool.selected) { logIfDebugging('Rendering generation bbox'); - manager.konvaApi.renderBbox(); + manager.renderBbox(); } if ( @@ -459,7 +433,7 @@ export const initializeRenderer = ( canvasV2.regions.entities !== prevCanvasV2.regions.entities ) { logIfDebugging('Arranging entities'); - manager.konvaApi.arrangeEntities(); + manager.arrangeEntities(); } prevCanvasV2 = canvasV2; @@ -473,16 +447,16 @@ export const initializeRenderer = ( // We can use a resize observer to ensure the stage always fits the container. We also need to re-render the bg and // document bounds overlay when the stage is resized. - const resizeObserver = new ResizeObserver(manager.konvaApi.fitStageToContainer); + const resizeObserver = new ResizeObserver(manager.fitStageToContainer); resizeObserver.observe(container); - manager.konvaApi.fitStageToContainer(); + manager.fitStageToContainer(); const unsubscribeRenderer = subscribe(renderCanvas); logIfDebugging('First render of konva stage'); // On first render, the document should be fit to the stage. - manager.konvaApi.fitDocumentToStage(); - manager.konvaApi.renderToolPreview(); + manager.fitDocumentToStage(); + manager.renderToolPreview(); renderCanvas(); return () => { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts index d02ecd485a..70b05cf104 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts @@ -45,7 +45,7 @@ export const getFitStageToContainer = (manager: KonvaNodeManager) => { scale: stage.scaleX(), }); manager.konvaApi.renderBackground(); - manager.konvaApi.renderDocumentOverlay(); + manager.renderDocumentOverlay(); } return fitStageToContainer; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts index 49aa761246..54e73bc582 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts @@ -2,7 +2,7 @@ import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit'; import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils'; import { getBrushLineId, getEraserLineId, getRectShapeId } from 'features/controlLayers/konva/naming'; import type { CanvasV2State, CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types'; +import { DEFAULT_RGBA_COLOR, imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { IRect } from 'konva/lib/types'; @@ -309,7 +309,7 @@ export const regionsReducers = { }, rgBrushLineAdded: { reducer: (state, action: PayloadAction) => { - const { id, points, lineId, color, width, clip } = action.payload; + const { id, points, lineId, width, clip } = action.payload; const rg = selectRG(state, id); if (!rg) { return; @@ -319,7 +319,7 @@ export const regionsReducers = { type: 'brush_line', points, strokeWidth: width, - color, + color: DEFAULT_RGBA_COLOR, clip, }); rg.bboxNeedsUpdate = true; @@ -366,7 +366,7 @@ export const regionsReducers = { }, rgRectAdded: { reducer: (state, action: PayloadAction) => { - const { id, rect, rectId, color } = action.payload; + const { id, rect, rectId } = action.payload; if (rect.height === 0 || rect.width === 0) { // Ignore zero-area rectangles return; @@ -379,7 +379,7 @@ export const regionsReducers = { type: 'rect_shape', id: getRectShapeId(id, rectId), ...rect, - color, + color: DEFAULT_RGBA_COLOR, }); rg.bboxNeedsUpdate = true; rg.imageCache = null;