diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 0f4e4c05b2..955162cd4c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -1,11 +1,4 @@ import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background'; -import { - renderDocumentBoundsOverlay, - renderToolPreview, - scaleToolPreview, -} from 'features/controlLayers/konva/renderers/preview'; -import { fitDocumentToStage } from 'features/controlLayers/konva/renderers/stage'; import { getScaledFlooredCursorPosition } from 'features/controlLayers/konva/util'; import type { BrushLineAddedArg, @@ -52,7 +45,6 @@ type Arg = { getSelectedEntity: () => CanvasEntity | null; getSpaceKey: () => boolean; setSpaceKey: (val: boolean) => void; - getDocument: () => CanvasV2State['document']; getBbox: () => CanvasV2State['bbox']; getSettings: () => CanvasV2State['settings']; onBrushLineAdded: (arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => void; @@ -160,7 +152,6 @@ export const setStageEventHandlers = ({ getSelectedEntity, getSpaceKey, setSpaceKey, - getDocument, getBbox, getSettings, onBrushLineAdded, @@ -176,16 +167,7 @@ export const setStageEventHandlers = ({ stage.on('mouseenter', () => { const tool = getToolState().selected; stage.findOne(`#${PREVIEW_TOOL_GROUP_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region mousedown @@ -306,16 +288,7 @@ export const setStageEventHandlers = ({ setLastAddedPoint(pos); } } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region mouseup @@ -354,16 +327,7 @@ export const setStageEventHandlers = ({ setLastMouseDownPos(null); } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region mousemove @@ -469,17 +433,7 @@ export const setStageEventHandlers = ({ } } } - - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region mouseleave @@ -508,16 +462,7 @@ export const setStageEventHandlers = ({ } } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region wheel @@ -558,21 +503,11 @@ export const setStageEventHandlers = ({ stage.scaleY(newScale); stage.position(newPos); setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale }); - renderBackgroundLayer(manager); - scaleToolPreview(manager, getToolState()); - renderDocumentBoundsOverlay(manager, getDocument); + manager.renderers.renderBackground(); + manager.renderers.renderDocumentOverlay(); } } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region dragmove @@ -584,18 +519,9 @@ export const setStageEventHandlers = ({ height: stage.height(), scale: stage.scaleX(), }); - renderBackgroundLayer(manager); - renderDocumentBoundsOverlay(manager, getDocument); - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderBackground(); + manager.renderers.renderDocumentOverlay(); + manager.renderers.renderToolPreview(); }); //#region dragend @@ -608,16 +534,7 @@ export const setStageEventHandlers = ({ height: stage.height(), scale: stage.scaleX(), }); - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }); //#region key @@ -638,22 +555,12 @@ export const setStageEventHandlers = ({ setTool('view'); setSpaceKey(true); } else if (e.key === 'r') { - const stageAttrs = fitDocumentToStage(stage, getDocument()); - setStageAttrs(stageAttrs); - scaleToolPreview(manager, getToolState()); - renderBackgroundLayer(manager); - renderDocumentBoundsOverlay(manager, getDocument); + manager.renderers.fitDocumentToStage(); + manager.renderers.renderToolPreview(); + manager.renderers.renderBackground(); + manager.renderers.renderDocumentOverlay(); } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.renderToolPreview(); }; window.addEventListener('keydown', onKeyDown); @@ -671,16 +578,7 @@ export const setStageEventHandlers = ({ setToolBuffer(null); setSpaceKey(false); } - renderToolPreview( - manager, - getToolState(), - getCurrentFill(), - getSelectedEntity(), - getLastCursorPos(), - getLastMouseDownPos(), - getIsDrawing(), - getIsMouseDown() - ); + manager.renderers.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 d1cba7bd71..1ac2e273c6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts @@ -1,20 +1,6 @@ -import { createBackgroundLayer } from 'features/controlLayers/konva/renderers/background'; -import { - createBboxPreview, - createDocumentOverlay, - createPreviewLayer, - createToolPreview, -} from 'features/controlLayers/konva/renderers/preview'; -import type { - BrushLine, - CanvasEntity, - CanvasV2State, - EraserLine, - ImageObject, - Rect, - RectShape, -} from 'features/controlLayers/store/types'; +import type { BrushLine, CanvasEntity, EraserLine, ImageObject, RectShape } from 'features/controlLayers/store/types'; import type Konva from 'konva'; +import { assert } from 'tsafe'; export type BrushLineObjectRecord = { id: string; @@ -50,61 +36,62 @@ export type ImageObjectRecord = { type ObjectRecord = BrushLineObjectRecord | EraserLineObjectRecord | RectShapeObjectRecord | ImageObjectRecord; +type KonvaRenderers = { + renderRegions: () => void; + renderLayers: () => void; + renderControlAdapters: () => void; + renderInpaintMask: () => void; + renderBbox: () => void; + renderDocumentOverlay: () => void; + renderBackground: () => void; + renderToolPreview: () => void; + fitDocumentToStage: () => void; + arrangeEntities: () => 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; + }; +}; + export class KonvaNodeManager { stage: Konva.Stage; adapters: Map; - background: { layer: Konva.Layer }; - preview: { - 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; - }; - }; + _background: BackgroundLayer | null; + _preview: PreviewLayer | null; + _renderers: KonvaRenderers | null; - constructor( - stage: Konva.Stage, - getBbox: () => CanvasV2State['bbox'], - onBboxTransformed: (bbox: Rect) => void, - getShiftKey: () => boolean, - getCtrlKey: () => boolean, - getMetaKey: () => boolean, - getAltKey: () => boolean - ) { + constructor(stage: Konva.Stage) { this.stage = stage; + this._renderers = null; + this._preview = null; + this._background = null; this.adapters = new Map(); - - this.background = { layer: createBackgroundLayer() }; - this.stage.add(this.background.layer); - - this.preview = { - layer: createPreviewLayer(), - bbox: createBboxPreview(stage, getBbox, onBboxTransformed, getShiftKey, getCtrlKey, getMetaKey, getAltKey), - tool: createToolPreview(stage), - documentOverlay: createDocumentOverlay(), - }; - this.preview.layer.add(this.preview.bbox.group); - this.preview.layer.add(this.preview.tool.group); - this.preview.layer.add(this.preview.documentOverlay.group); - this.stage.add(this.preview.layer); } add(entity: CanvasEntity, konvaLayer: Konva.Layer, konvaObjectGroup: Konva.Group): KonvaEntityAdapter { @@ -133,6 +120,33 @@ export class KonvaNodeManager { adapter.konvaLayer.destroy(); return this.adapters.delete(id); } + + set renderers(renderers: KonvaRenderers) { + this._renderers = renderers; + } + + get renderers(): KonvaRenderers { + assert(this._renderers !== null, 'Konva renderers have not been set'); + return this._renderers; + } + + set preview(preview: PreviewLayer) { + this._preview = preview; + } + + get preview(): PreviewLayer { + assert(this._preview !== null, 'Konva preview layer has not been set'); + return this._preview; + } + + set background(background: BackgroundLayer) { + this._background = background; + } + + get background(): BackgroundLayer { + assert(this._background !== null, 'Konva background layer has not been set'); + return this._background; + } } export class KonvaEntityAdapter { 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 8e5650a3b2..6c040aaea5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts @@ -1,23 +1,37 @@ import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import type { ControlAdapterEntity, LayerEntity, RegionEntity } from 'features/controlLayers/store/types'; +import type { CanvasV2State } from 'features/controlLayers/store/types'; -export const arrangeEntities = ( - manager: KonvaNodeManager, - layers: LayerEntity[], - controlAdapters: ControlAdapterEntity[], - regions: RegionEntity[] -): void => { - let zIndex = 0; - manager.background.layer.zIndex(++zIndex); - for (const layer of layers) { - manager.get(layer.id)?.konvaLayer.zIndex(++zIndex); - } - for (const ca of controlAdapters) { - manager.get(ca.id)?.konvaLayer.zIndex(++zIndex); - } - for (const rg of regions) { - manager.get(rg.id)?.konvaLayer.zIndex(++zIndex); - } - manager.get('inpaint_mask')?.konvaLayer.zIndex(++zIndex); - manager.preview.layer.zIndex(++zIndex); -}; +/** + * Gets a function to arrange the entities in the konva stage. + * @param manager The konva node manager + * @param getLayerEntityStates A function to get all layer entity states + * @param getControlAdapterEntityStates A function to get all control adapter entity states + * @param getRegionEntityStates A function to get all region entity states + * @returns An arrange entities function + */ +export const getArrangeEntities = + (arg: { + manager: KonvaNodeManager; + getLayerEntityStates: () => CanvasV2State['layers']['entities']; + getControlAdapterEntityStates: () => CanvasV2State['controlAdapters']['entities']; + getRegionEntityStates: () => CanvasV2State['regions']['entities']; + }) => + (): void => { + const { manager, getLayerEntityStates, getControlAdapterEntityStates, getRegionEntityStates } = arg; + const layers = getLayerEntityStates(); + const controlAdapters = getControlAdapterEntityStates(); + const regions = getRegionEntityStates(); + let zIndex = 0; + manager.background.layer.zIndex(++zIndex); + for (const layer of layers) { + manager.get(layer.id)?.konvaLayer.zIndex(++zIndex); + } + for (const ca of controlAdapters) { + manager.get(ca.id)?.konvaLayer.zIndex(++zIndex); + } + for (const rg of regions) { + manager.get(rg.id)?.konvaLayer.zIndex(++zIndex); + } + manager.get('inpaint_mask')?.konvaLayer.zIndex(++zIndex); + manager.preview.layer.zIndex(++zIndex); + }; 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 9fff013070..fe7ccebe92 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts @@ -6,6 +6,11 @@ import Konva from 'konva'; const baseGridLineColor = getArbitraryBaseColor(27); const fineGridLineColor = getArbitraryBaseColor(18); +/** + * Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller. + * @param scale The stage scale + * @returns The grid spacing based on the stage scale + */ const getGridSpacing = (scale: number): number => { if (scale >= 2) { return 8; @@ -25,9 +30,19 @@ const getGridSpacing = (scale: number): number => { return 256; }; +/** + * Creates the background konva layer. + * @returns The background konva layer + */ export const createBackgroundLayer = (): Konva.Layer => new Konva.Layer({ id: BACKGROUND_LAYER_ID, listening: false }); -export const renderBackgroundLayer = (manager: KonvaNodeManager): void => { +/** + * Gets a render function for the background layer. + * @param arg.manager The konva node manager + * @returns A function to render the background grid + */ +export const getRenderBackground = (arg: { manager: KonvaNodeManager }) => (): void => { + const { manager } = arg; const background = manager.background.layer; background.zIndex(0); const scale = manager.stage.scaleX(); 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 952567a74d..58b8f32922 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts @@ -6,7 +6,7 @@ import { createObjectGroup, updateImageSource, } from 'features/controlLayers/konva/renderers/objects'; -import type { ControlAdapterEntity } from 'features/controlLayers/store/types'; +import type { CanvasV2State, ControlAdapterEntity } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { isEqual } from 'lodash-es'; import { assert } from 'tsafe'; @@ -17,8 +17,8 @@ import { assert } from 'tsafe'; */ /** - * Creates a control adapter layer. - * @param stage The konva stage + * 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 => { @@ -37,11 +37,9 @@ const getControlAdapter = (manager: KonvaNodeManager, entity: ControlAdapterEnti }; /** - * Renders a control adapter layer. If the layer doesn't already exist, it is created. Otherwise, the layer is updated - * with the current image source and attributes. - * @param stage The konva stage - * @param entity The control adapter layer state - * @param getImageDTO A function to retrieve an image DTO from the server, used to update the image source + * 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); @@ -101,14 +99,27 @@ export const renderControlAdapter = async (manager: KonvaNodeManager, entity: Co } }; -export const renderControlAdapters = (manager: KonvaNodeManager, entities: ControlAdapterEntity[]): void => { - // Destroy nonexistent layers - for (const adapters of manager.getAll('control_adapter')) { - if (!entities.find((ca) => ca.id === adapters.id)) { - manager.destroy(adapters.id); +/** + * Gets a function to render all control adapters. + * @param manager The konva node manager + * @param getControlAdapterEntityStates A function to get all control adapter entities + * @returns A function to render all control adapters + */ +export const getRenderControlAdapters = + (arg: { + manager: KonvaNodeManager; + getControlAdapterEntityStates: () => CanvasV2State['controlAdapters']['entities']; + }) => + (): void => { + const { manager, getControlAdapterEntityStates } = arg; + const entities = getControlAdapterEntityStates(); + // Destroy nonexistent layers + for (const adapters of manager.getAll('control_adapter')) { + if (!entities.find((ca) => ca.id === adapters.id)) { + manager.destroy(adapters.id); + } } - } - for (const entity of entities) { - renderControlAdapter(manager, entity); - } -}; + for (const entity of entities) { + renderControlAdapter(manager, entity); + } + }; 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 afa09e0e17..ed1f155eae 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts @@ -16,24 +16,11 @@ import { getRectShape, } from 'features/controlLayers/konva/renderers/objects'; import { mapId } from 'features/controlLayers/konva/util'; -import type { - CanvasEntity, - CanvasEntityIdentifier, - InpaintMaskEntity, - PosChangedArg, - Tool, -} from 'features/controlLayers/store/types'; +import type { CanvasEntity, CanvasV2State, InpaintMaskEntity, PosChangedArg } from 'features/controlLayers/store/types'; import Konva from 'konva'; /** - * Logic for creating and rendering regional guidance layers. - * - * Some special handling is needed to render layer opacity correctly using a "compositing rect". See the comments - * in `renderRGLayer`. - */ - -/** - * Creates the "compositing rect" for a regional guidance layer. + * Creates the "compositing rect" for the inpaint mask. * @param konvaLayer The konva layer */ const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { @@ -43,23 +30,24 @@ const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { }; /** - * Creates a regional guidance layer. - * @param stage The konva stage - * @param entity The regional guidance layer state - * @param onLayerPosChanged Callback for when the layer's position changes + * 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, - entity: InpaintMaskEntity, + entityState: InpaintMaskEntity, onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void ): KonvaEntityAdapter => { - const adapter = manager.get(entity.id); + 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: entity.id, + id: entityState.id, name: INPAINT_MASK_LAYER_NAME, draggable: true, dragDistance: 0, @@ -69,166 +57,175 @@ const getInpaintMask = ( // the position - we do not need to call this on the `dragmove` event. if (onPosChanged) { konvaLayer.on('dragend', function (e) { - onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'inpaint_mask'); + onPosChanged({ id: entityState.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'inpaint_mask'); }); } const konvaObjectGroup = createObjectGroup(konvaLayer, INPAINT_MASK_LAYER_OBJECT_GROUP_NAME); - return manager.add(entity, konvaLayer, konvaObjectGroup); + return manager.add(entityState, konvaLayer, konvaObjectGroup); }; /** - * Renders a raster layer. - * @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 + * Gets the inpaint mask render function. + * @param manager The konva node manager + * @param getEntityState A function to get the inpaint mask entity state + * @param getMaskOpacity A function to get the mask opacity + * @param getToolState A function to get the tool state + * @param getSelectedEntity A function to get the selected entity + * @param onPosChanged Callback for when the position changes (e.g. the entity is dragged) + * @returns The inpaint mask render function */ -export const renderInpaintMask = ( - manager: KonvaNodeManager, - entity: InpaintMaskEntity, - globalMaskLayerOpacity: number, - tool: Tool, - selectedEntityIdentifier: CanvasEntityIdentifier | null, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): void => { - const adapter = getInpaintMask(manager, entity, onPosChanged); +export const getRenderInpaintMask = + (arg: { + manager: KonvaNodeManager; + getInpaintMaskEntityState: () => CanvasV2State['inpaintMask']; + getMaskOpacity: () => number; + getToolState: () => CanvasV2State['tool']; + getSelectedEntity: () => CanvasEntity | null; + onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void; + }) => + (): void => { + const { manager, getInpaintMaskEntityState, getMaskOpacity, getToolState, getSelectedEntity, onPosChanged } = arg; + const entity = getInpaintMaskEntityState(); + const globalMaskLayerOpacity = getMaskOpacity(); + const toolState = getToolState(); + const selectedEntity = getSelectedEntity(); + const adapter = getInpaintMask(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), - }); + // 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), + }); - // Convert the color to a string, stripping the alpha - the object group will handle opacity. - const rgbColor = rgbColorToString(entity.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 = 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); + 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); + groupNeedsCache = true; + } + } + + for (const obj of entity.objects) { + if (obj.type === 'brush_line') { + const objectRecord = getBrushLine(adapter, obj, INPAINT_MASK_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, INPAINT_MASK_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, INPAINT_MASK_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; } - } - for (const obj of entity.objects) { - if (obj.type === 'brush_line') { - const objectRecord = getBrushLine(adapter, obj, INPAINT_MASK_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, INPAINT_MASK_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, INPAINT_MASK_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()) { + if (adapter.konvaObjectGroup.getChildren().length === 0) { + // No objects - clear the cache to reset the previous pixel data adapter.konvaObjectGroup.clearCache(); + return; } - // 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(); + const compositingRect = + adapter.konvaLayer.findOne(`.${COMPOSITING_RECT_NAME}`) ?? createCompositingRect(adapter.konvaLayer); + const isSelected = selectedEntity?.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 && toolState.selected !== '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); } - // Updating group opacity does not require re-caching - adapter.konvaObjectGroup.opacity(globalMaskLayerOpacity); - } - // const bboxRect = - // regionMap.konvaLayer.findOne(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rg, regionMap.konvaLayer); + // 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); - // } -}; + // 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/layers.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts index 475692a599..a2ddceeef8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts @@ -15,7 +15,7 @@ import { getRectShape, } 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 { CanvasEntity, CanvasV2State, LayerEntity, PosChangedArg, Tool } from 'features/controlLayers/store/types'; import Konva from 'konva'; /** @@ -23,10 +23,11 @@ import Konva from 'konva'; */ /** - * Creates a raster layer. - * @param stage The konva stage - * @param entity The raster layer state + * 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, @@ -58,9 +59,9 @@ const getLayer = ( }; /** - * Renders a regional guidance layer. - * @param stage The konva stage - * @param entity The regional guidance layer state + * 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 */ @@ -133,19 +134,32 @@ export const renderLayer = async ( adapter.konvaObjectGroup.opacity(entity.opacity); }; -export const renderLayers = ( - manager: KonvaNodeManager, - entities: LayerEntity[], - tool: Tool, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): void => { - // Destroy nonexistent layers - for (const adapter of manager.getAll('layer')) { - if (!entities.find((l) => l.id === adapter.id)) { - manager.destroy(adapter.id); +/** + * Gets a function to render all layers. + * @param manager The konva node manager + * @param getLayerEntityStates A function to get all layer entities + * @param getToolState A function to get the current tool state + * @param onPosChanged Callback for when the layer's position changes + * @returns A function to render all layers + */ +export const getRenderLayers = + (arg: { + manager: KonvaNodeManager; + getLayerEntityStates: () => CanvasV2State['layers']['entities']; + getToolState: () => CanvasV2State['tool']; + onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void; + }) => + (): void => { + const { manager, getLayerEntityStates, getToolState, onPosChanged } = arg; + const entities = getLayerEntityStates(); + 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); + } } - } - for (const entity of entities) { - renderLayer(manager, entity, tool, onPosChanged); - } -}; + for (const entity of entities) { + renderLayer(manager, entity, tool.selected, onPosChanged); + } + }; 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 3926dfadc0..18ff6bfb15 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts @@ -19,14 +19,29 @@ import { PREVIEW_TOOL_GROUP_ID, } from 'features/controlLayers/konva/naming'; import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import type { CanvasEntity, CanvasV2State, RgbaColor, Tool } from 'features/controlLayers/store/types'; +import type { CanvasEntity, CanvasV2State, RgbaColor } from 'features/controlLayers/store/types'; import Konva from 'konva'; import type { IRect, Vector2d } from 'konva/lib/types'; import { atom } from 'nanostores'; +/** + * Creates the konva preview layer. + * @returns The konva preview layer + */ export const createPreviewLayer = (): Konva.Layer => new Konva.Layer({ id: PREVIEW_LAYER_ID, listening: true }); -export const createBboxPreview = ( +/** + * Creates the bbox konva nodes. + * @param stage The konva stage + * @param getBbox A function to get the bbox + * @param onBboxTransformed A callback for when the bbox is transformed + * @param getShiftKey A function to get the shift key state + * @param getCtrlKey A function to get the ctrl key state + * @param getMetaKey A function to get the meta key state + * @param getAltKey A function to get the alt key state + * @returns The bbox nodes + */ +export const createBboxNodes = ( stage: Konva.Stage, getBbox: () => IRect, onBboxTransformed: (bbox: IRect) => void, @@ -227,18 +242,40 @@ const ALL_ANCHORS: string[] = [ const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; const NO_ANCHORS: string[] = []; -export const renderBboxPreview = (manager: KonvaNodeManager, bbox: IRect, tool: Tool): void => { - manager.preview.bbox.group.listening(tool === 'bbox'); - // This updates the bbox during transformation - manager.preview.bbox.rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1, listening: tool === 'bbox' }); - manager.preview.bbox.transformer.setAttrs({ - listening: tool === 'bbox', - enabledAnchors: tool === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, - }); -}; +/** + * Gets the bbox render function. + * @param manager The konva node manager + * @param getBbox A function to get the bbox + * @param getToolState A function to get the tool state + * @returns The bbox render function + */ +export const getRenderBbox = + (manager: KonvaNodeManager, getBbox: () => CanvasV2State['bbox'], getToolState: () => CanvasV2State['tool']) => + (): void => { + const bbox = getBbox(); + const toolState = getToolState(); + manager.preview.bbox.group.listening(toolState.selected === 'bbox'); + // This updates the bbox during transformation + manager.preview.bbox.rect.setAttrs({ + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, + scaleX: 1, + scaleY: 1, + listening: toolState.selected === 'bbox', + }); + manager.preview.bbox.transformer.setAttrs({ + listening: toolState.selected === 'bbox', + enabledAnchors: toolState.selected === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, + }); + }; -export const createToolPreview = (stage: Konva.Stage): KonvaNodeManager['preview']['tool'] => { - const scale = stage.scaleX(); +/** + * Gets the tool preview konva nodes. + * @returns The tool preview konva nodes + */ +export const createToolPreviewNodes = (): KonvaNodeManager['preview']['tool'] => { const group = new Konva.Group({ id: PREVIEW_TOOL_GROUP_ID }); // Create the brush preview group & circles @@ -253,7 +290,7 @@ export const createToolPreview = (stage: Konva.Stage): KonvaNodeManager['preview id: PREVIEW_BRUSH_BORDER_INNER_ID, listening: false, stroke: BRUSH_BORDER_INNER_COLOR, - strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, strokeEnabled: true, }); brushGroup.add(brushBorderInner); @@ -261,7 +298,7 @@ export const createToolPreview = (stage: Konva.Stage): KonvaNodeManager['preview id: PREVIEW_BRUSH_BORDER_OUTER_ID, listening: false, stroke: BRUSH_BORDER_OUTER_COLOR, - strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, strokeEnabled: true, }); brushGroup.add(brushBorderOuter); @@ -290,111 +327,138 @@ export const createToolPreview = (stage: Konva.Stage): KonvaNodeManager['preview }; /** - * Renders the preview layer. - * @param stage The konva stage - * @param tool The selected tool - * @param currentFill The selected layer's color - * @param selectedEntity The selected layer's type - * @param globalMaskLayerOpacity The global mask layer opacity - * @param cursorPos The cursor position - * @param lastMouseDownPos The position of the last mouse down event - used for the rect tool - * @param brushSize The brush size + * Gets the tool preview (brush, eraser, rect) render function. + * @param arg.manager The konva node manager + * @param arg.getToolState The selected tool + * @param arg.currentFill The selected layer's color + * @param arg.selectedEntity The selected layer's type + * @param arg.globalMaskLayerOpacity The global mask layer opacity + * @param arg.cursorPos The cursor position + * @param arg.lastMouseDownPos The position of the last mouse down event - used for the rect tool + * @param arg.brushSize The brush size + * @returns The tool preview render function */ -export const renderToolPreview = ( - manager: KonvaNodeManager, - toolState: CanvasV2State['tool'], - currentFill: RgbaColor, - selectedEntity: CanvasEntity | null, - cursorPos: Vector2d | null, - lastMouseDownPos: Vector2d | null, - isDrawing: boolean, - isMouseDown: boolean -): void => { - const stage = manager.stage; - const layerCount = manager.adapters.size; - const tool = toolState.selected; - const isDrawableEntity = - selectedEntity?.type === 'regional_guidance' || - selectedEntity?.type === 'layer' || - selectedEntity?.type === 'inpaint_mask'; +export const getRenderToolPreview = + (arg: { + manager: KonvaNodeManager; + getToolState: () => CanvasV2State['tool']; + getCurrentFill: () => RgbaColor; + getSelectedEntity: () => CanvasEntity | null; + getLastCursorPos: () => Vector2d | null; + getLastMouseDownPos: () => Vector2d | null; + getIsDrawing: () => boolean; + getIsMouseDown: () => boolean; + }) => + (): void => { + const { + manager, + getToolState, + getCurrentFill, + getSelectedEntity, + getLastCursorPos, + getLastMouseDownPos, + getIsDrawing, + getIsMouseDown, + } = arg; - // Update the stage's pointer style - if (tool === 'view') { - // View gets a hand - stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab'; - } else if (layerCount === 0) { - // We have no layers, so we should not render any tool - stage.container().style.cursor = 'default'; - } else if (!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'; - } + const stage = manager.stage; + const layerCount = manager.adapters.size; + const toolState = getToolState(); + const currentFill = getCurrentFill(); + const selectedEntity = getSelectedEntity(); + const cursorPos = getLastCursorPos(); + const lastMouseDownPos = getLastMouseDownPos(); + const isDrawing = getIsDrawing(); + const isMouseDown = getIsMouseDown(); + const tool = toolState.selected; + const isDrawableEntity = + selectedEntity?.type === 'regional_guidance' || + selectedEntity?.type === 'layer' || + selectedEntity?.type === 'inpaint_mask'; - stage.draggable(tool === 'view'); - - if (!cursorPos || layerCount === 0 || !isDrawableEntity) { - // We can bail early if the mouse isn't over the stage or there are no layers - manager.preview.tool.group.visible(false); - } else { - manager.preview.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; - manager.preview.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 - manager.preview.tool.brush.innerBorder.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius }); - - // Update the outer border of the brush preview - manager.preview.tool.brush.outerBorder.setAttrs({ - x: cursorPos.x, - y: cursorPos.y, - radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, - }); - - scaleToolPreview(manager, toolState); - - manager.preview.tool.brush.group.visible(true); - } else { - manager.preview.tool.brush.group.visible(false); + // Update the stage's pointer style + if (tool === 'view') { + // View gets a hand + stage.container().style.cursor = isMouseDown ? 'grabbing' : 'grab'; + } else if (layerCount === 0) { + // We have no layers, so we should not render any tool + stage.container().style.cursor = 'default'; + } else if (!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'; } - if (cursorPos && lastMouseDownPos && tool === 'rect') { - manager.preview.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 { - manager.preview.tool.rect.rect.visible(false); - } - } -}; + stage.draggable(tool === 'view'); -export const scaleToolPreview = (manager: KonvaNodeManager, toolState: CanvasV2State['tool']): void => { + if (!cursorPos || layerCount === 0 || !isDrawableEntity) { + // We can bail early if the mouse isn't over the stage or there are no layers + manager.preview.tool.group.visible(false); + } else { + manager.preview.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; + manager.preview.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 + manager.preview.tool.brush.innerBorder.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius }); + + // Update the outer border of the brush preview + manager.preview.tool.brush.outerBorder.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, + }); + + scaleToolPreview(manager, toolState); + + manager.preview.tool.brush.group.visible(true); + } else { + manager.preview.tool.brush.group.visible(false); + } + + if (cursorPos && lastMouseDownPos && tool === 'rect') { + manager.preview.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 { + manager.preview.tool.rect.rect.visible(false); + } + } + }; + +/** + * Scales the tool preview nodes. Depending on the scale of the stage, the border width and radius of the brush preview + * need to be adjusted. + * @param manager The konva node manager + * @param toolState The tool state + */ +const scaleToolPreview = (manager: KonvaNodeManager, toolState: CanvasV2State['tool']): void => { const scale = manager.stage.scaleX(); const radius = (toolState.selected === 'brush' ? toolState.brush.width : toolState.eraser.width) / 2; manager.preview.tool.brush.innerBorder.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale); @@ -404,6 +468,10 @@ export const scaleToolPreview = (manager: KonvaNodeManager, toolState: CanvasV2S }); }; +/** + * Creates the document overlay konva nodes. + * @returns The document overlay konva nodes + */ export const createDocumentOverlay = (): KonvaNodeManager['preview']['documentOverlay'] => { const group = new Konva.Group({ id: 'document_overlay_group', listening: false }); const outerRect = new Konva.Rect({ @@ -423,32 +491,37 @@ export const createDocumentOverlay = (): KonvaNodeManager['preview']['documentOv return { group, innerRect, outerRect }; }; -export const renderDocumentBoundsOverlay = ( - manager: KonvaNodeManager, - getDocument: () => CanvasV2State['document'] -): void => { - const document = getDocument(); - const stage = manager.stage; +/** + * Gets the document overlay render function. + * @param arg.manager The konva node manager + * @param arg.getDocument A function to get the document state + * @returns The document overlay render function + */ +export const getRenderDocumentOverlay = + (arg: { manager: KonvaNodeManager; getDocument: () => CanvasV2State['document'] }) => (): void => { + const { manager, getDocument } = arg; + const document = getDocument(); + const stage = manager.stage; - manager.preview.documentOverlay.group.zIndex(0); + manager.preview.documentOverlay.group.zIndex(0); - const x = stage.x(); - const y = stage.y(); - const width = stage.width(); - const height = stage.height(); - const scale = stage.scaleX(); + const x = stage.x(); + const y = stage.y(); + const width = stage.width(); + const height = stage.height(); + const scale = stage.scaleX(); - manager.preview.documentOverlay.outerRect.setAttrs({ - offsetX: x / scale, - offsetY: y / scale, - width: width / scale, - height: height / scale, - }); + manager.preview.documentOverlay.outerRect.setAttrs({ + offsetX: x / scale, + offsetY: y / scale, + width: width / scale, + height: height / scale, + }); - manager.preview.documentOverlay.innerRect.setAttrs({ - x: 0, - y: 0, - width: document.width, - height: document.height, - }); -}; + manager.preview.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 ae830055e2..79badb3e3f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts @@ -19,19 +19,13 @@ import { mapId } from 'features/controlLayers/konva/util'; import type { CanvasEntity, CanvasEntityIdentifier, + CanvasV2State, PosChangedArg, RegionEntity, Tool, } from 'features/controlLayers/store/types'; import Konva from 'konva'; -/** - * Logic for creating and rendering regional guidance layers. - * - * Some special handling is needed to render layer opacity correctly using a "compositing rect". See the comments - * in `renderRGLayer`. - */ - /** * Creates the "compositing rect" for a regional guidance layer. * @param konvaLayer The konva layer @@ -43,10 +37,11 @@ const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { }; /** - * Creates a regional guidance layer. + * 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, @@ -78,7 +73,7 @@ const getRegion = ( }; /** - * Renders a raster layer. + * Renders a region. * @param stage The konva stage * @param entity The regional guidance layer state * @param globalMaskLayerOpacity The global mask layer opacity @@ -233,21 +228,40 @@ export const renderRegion = ( // } }; -export const renderRegions = ( - manager: KonvaNodeManager, - entities: RegionEntity[], - maskOpacity: number, - tool: Tool, - selectedEntityIdentifier: CanvasEntityIdentifier | null, - onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void -): void => { - // Destroy nonexistent layers - for (const adapter of manager.getAll('regional_guidance')) { - if (!entities.find((rg) => rg.id === adapter.id)) { - manager.destroy(adapter.id); +/** + * Gets a function to render all regions. + * @param arg.manager The konva node manager + * @param arg.getRegionEntityStates A function to get all region entities + * @param arg.getMaskOpacity A function to get the mask opacity + * @param arg.getToolState A function to get the tool state + * @param arg.getSelectedEntity A function to get the selectedEntity + * @param arg.onPosChanged A callback for when the position of an entity changes + * @returns A function to render all regions + */ +export const getRenderRegions = + (arg: { + manager: KonvaNodeManager; + getRegionEntityStates: () => CanvasV2State['regions']['entities']; + getMaskOpacity: () => number; + getToolState: () => CanvasV2State['tool']; + getSelectedEntity: () => CanvasEntity | null; + onPosChanged?: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void; + }) => + () => { + const { manager, getRegionEntityStates, getMaskOpacity, getToolState, getSelectedEntity, onPosChanged } = arg; + const entities = getRegionEntityStates(); + 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); + } } - } - for (const entity of entities) { - renderRegion(manager, entity, maskOpacity, tool, selectedEntityIdentifier, onPosChanged); - } -}; + + for (const entity of entities) { + renderRegion(manager, entity, maskOpacity, toolState.selected, selectedEntity, onPosChanged); + } + }; 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 08a996581d..99115297f7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts @@ -5,19 +5,23 @@ import { $isDebugging } from 'app/store/nanostores/isDebugging'; import type { RootState } from 'app/store/store'; import { setStageEventHandlers } from 'features/controlLayers/konva/events'; import { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -import { arrangeEntities } from 'features/controlLayers/konva/renderers/arrange'; -import { renderBackgroundLayer } from 'features/controlLayers/konva/renderers/background'; +import { getArrangeEntities } from 'features/controlLayers/konva/renderers/arrange'; +import { createBackgroundLayer, getRenderBackground } from 'features/controlLayers/konva/renderers/background'; import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox'; -import { renderControlAdapters } from 'features/controlLayers/konva/renderers/controlAdapters'; -import { renderInpaintMask } from 'features/controlLayers/konva/renderers/inpaintMask'; -import { renderLayers } from 'features/controlLayers/konva/renderers/layers'; +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 { - renderBboxPreview, - renderDocumentBoundsOverlay, - scaleToolPreview, + createBboxNodes, + createDocumentOverlay, + createPreviewLayer, + createToolPreviewNodes, + getRenderBbox, + getRenderDocumentOverlay, + getRenderToolPreview, } from 'features/controlLayers/konva/renderers/preview'; -import { renderRegions } from 'features/controlLayers/konva/renderers/regions'; -import { fitDocumentToStage } from 'features/controlLayers/konva/renderers/stage'; +import { getRenderRegions } from 'features/controlLayers/konva/renderers/regions'; +import { getFitDocumentToStage } from 'features/controlLayers/konva/renderers/stage'; import { $stageAttrs, bboxChanged, @@ -67,8 +71,8 @@ export const $nodeManager = atom(null); /** * Initializes the canvas renderer. It subscribes to the redux store and listens for changes directly, bypassing the * react rendering cycle entirely, improving canvas performance. - * @param store The Redux store - * @param stage The Konva stage + * @param store The redux store + * @param stage The konva stage * @param container The stage's target container element * @returns A cleanup function */ @@ -180,7 +184,7 @@ export const initializeRenderer = ( dispatch(toolBufferChanged(toolBuffer)); }; - const _getSelectedEntity = (canvasV2: CanvasV2State): CanvasEntity | null => { + const selectSelectedEntity = (canvasV2: CanvasV2State): CanvasEntity | null => { const identifier = canvasV2.selectedEntityIdentifier; let selectedEntity: CanvasEntity | null = null; if (!identifier) { @@ -202,10 +206,14 @@ export const initializeRenderer = ( return selectedEntity; }; - const _getCurrentFill = (canvasV2: CanvasV2State, selectedEntity: CanvasEntity | null) => { + const selectCurrentFill = (canvasV2: CanvasV2State, selectedEntity: CanvasEntity | null) => { let currentFill: RgbaColor = canvasV2.tool.fill; - if (selectedEntity && selectedEntity.type === 'regional_guidance') { - currentFill = { ...selectedEntity.fill, a: canvasV2.settings.maskOpacity }; + if (selectedEntity) { + if (selectedEntity.type === 'regional_guidance') { + currentFill = { ...selectedEntity.fill, a: canvasV2.settings.maskOpacity }; + } else if (selectedEntity.type === 'inpaint_mask') { + currentFill = { ...canvasV2.inpaintMask.fill, a: canvasV2.settings.maskOpacity }; + } } else { currentFill = canvasV2.tool.fill; } @@ -223,14 +231,20 @@ export const initializeRenderer = ( // Read-only state, derived from redux let prevCanvasV2 = getState().canvasV2; - let prevSelectedEntity: CanvasEntity | null = _getSelectedEntity(prevCanvasV2); - let prevCurrentFill: RgbaColor = _getCurrentFill(prevCanvasV2, prevSelectedEntity); + let canvasV2 = getState().canvasV2; + let prevSelectedEntity: CanvasEntity | null = selectSelectedEntity(prevCanvasV2); + let prevCurrentFill: RgbaColor = selectCurrentFill(prevCanvasV2, prevSelectedEntity); const getSelectedEntity = () => prevSelectedEntity; const getCurrentFill = () => prevCurrentFill; - const getBbox = () => getState().canvasV2.bbox; - const getDocument = () => getState().canvasV2.document; - const getToolState = () => getState().canvasV2.tool; - const getSettings = () => getState().canvasV2.settings; + const getBbox = () => canvasV2.bbox; + const getDocument = () => canvasV2.document; + const getToolState = () => canvasV2.tool; + const getSettings = () => canvasV2.settings; + const getRegionEntityStates = () => canvasV2.regions.entities; + const getLayerEntityStates = () => canvasV2.layers.entities; + const getControlAdapterEntityStates = () => canvasV2.controlAdapters.entities; + const getMaskOpacity = () => canvasV2.settings.maskOpacity; + const getInpaintMaskEntityState = () => canvasV2.inpaintMask; // Read-write state, ephemeral interaction state let isDrawing = false; @@ -269,10 +283,22 @@ export const initializeRenderer = ( spaceKey = val; }; - const manager = new KonvaNodeManager(stage, getBbox, onBboxTransformed, $shift.get, $ctrl.get, $meta.get, $alt.get); - console.log(manager); + const manager = new KonvaNodeManager(stage); $nodeManager.set(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(), + }; + 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); + const cleanupListeners = setStageEventHandlers({ manager, getToolState, @@ -292,7 +318,6 @@ export const initializeRenderer = ( getSpaceKey, setSpaceKey, setStageAttrs: $stageAttrs.set, - getDocument, getBbox, getSettings, onBrushLineAdded, @@ -309,16 +334,57 @@ export const initializeRenderer = ( // the entire state over when needed. const debouncedUpdateBboxes = debounce(updateBboxes, 300); + manager.renderers = { + renderRegions: getRenderRegions({ + manager, + getRegionEntityStates, + getMaskOpacity, + getToolState, + getSelectedEntity, + onPosChanged, + }), + renderLayers: getRenderLayers({ manager, getLayerEntityStates, getToolState, onPosChanged }), + renderControlAdapters: getRenderControlAdapters({ manager, getControlAdapterEntityStates }), + renderInpaintMask: getRenderInpaintMask({ + manager, + getInpaintMaskEntityState, + getMaskOpacity, + getToolState, + getSelectedEntity, + onPosChanged, + }), + renderBbox: getRenderBbox(manager, getBbox, getToolState), + renderToolPreview: getRenderToolPreview({ + manager, + getToolState, + getCurrentFill, + getSelectedEntity, + getLastCursorPos, + getLastMouseDownPos, + getIsDrawing, + getIsMouseDown, + }), + renderDocumentOverlay: getRenderDocumentOverlay({ manager, getDocument }), + renderBackground: getRenderBackground({ manager }), + fitDocumentToStage: getFitDocumentToStage({ manager, getDocument, setStageAttrs: $stageAttrs.set }), + arrangeEntities: getArrangeEntities({ + manager, + getLayerEntityStates, + getControlAdapterEntityStates, + getRegionEntityStates, + }), + }; + const renderCanvas = () => { - const { canvasV2 } = store.getState(); + canvasV2 = store.getState().canvasV2; if (prevCanvasV2 === canvasV2 && !isFirstRender) { logIfDebugging('No changes detected, skipping render'); return; } - const selectedEntity = _getSelectedEntity(canvasV2); - const currentFill = _getCurrentFill(canvasV2, selectedEntity); + const selectedEntity = selectSelectedEntity(canvasV2); + const currentFill = selectCurrentFill(canvasV2, selectedEntity); if ( isFirstRender || @@ -326,7 +392,7 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering layers'); - renderLayers(manager, canvasV2.layers.entities, canvasV2.tool.selected, onPosChanged); + manager.renderers.renderLayers(); } if ( @@ -336,14 +402,7 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering regions'); - renderRegions( - manager, - canvasV2.regions.entities, - canvasV2.settings.maskOpacity, - canvasV2.tool.selected, - canvasV2.selectedEntityIdentifier, - onPosChanged - ); + manager.renderers.renderRegions(); } if ( @@ -353,29 +412,22 @@ export const initializeRenderer = ( canvasV2.tool.selected !== prevCanvasV2.tool.selected ) { logIfDebugging('Rendering inpaint mask'); - renderInpaintMask( - manager, - canvasV2.inpaintMask, - canvasV2.settings.maskOpacity, - canvasV2.tool.selected, - canvasV2.selectedEntityIdentifier, - onPosChanged - ); + manager.renderers.renderInpaintMask(); } if (isFirstRender || canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities) { logIfDebugging('Rendering control adapters'); - renderControlAdapters(manager, canvasV2.controlAdapters.entities); + manager.renderers.renderControlAdapters(); } if (isFirstRender || canvasV2.document !== prevCanvasV2.document) { logIfDebugging('Rendering document bounds overlay'); - renderDocumentBoundsOverlay(manager, getDocument); + manager.renderers.renderDocumentOverlay(); } if (isFirstRender || canvasV2.bbox !== prevCanvasV2.bbox || canvasV2.tool.selected !== prevCanvasV2.tool.selected) { logIfDebugging('Rendering generation bbox'); - renderBboxPreview(manager, canvasV2.bbox, canvasV2.tool.selected); + manager.renderers.renderBbox(); } if ( @@ -395,7 +447,7 @@ export const initializeRenderer = ( canvasV2.regions.entities !== prevCanvasV2.regions.entities ) { logIfDebugging('Arranging entities'); - arrangeEntities(manager, canvasV2.layers.entities, canvasV2.controlAdapters.entities, canvasV2.regions.entities); + manager.renderers.arrangeEntities(); } prevCanvasV2 = canvasV2; @@ -419,8 +471,8 @@ export const initializeRenderer = ( height: stage.height(), scale: stage.scaleX(), }); - renderBackgroundLayer(manager); - renderDocumentBoundsOverlay(manager, getDocument); + manager.renderers.renderBackground(); + manager.renderers.renderDocumentOverlay(); }; const resizeObserver = new ResizeObserver(fitStageToContainer); @@ -431,10 +483,8 @@ export const initializeRenderer = ( logIfDebugging('First render of konva stage'); // On first render, the document should be fit to the stage. - const stageAttrs = fitDocumentToStage(stage, prevCanvasV2.document); - // The HUD displays some of the stage attributes, so we need to update it here. - $stageAttrs.set(stageAttrs); - scaleToolPreview(manager, getToolState()); + manager.renderers.fitDocumentToStage(); + manager.renderers.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 3af86041ed..b4741c88ac 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts @@ -1,16 +1,32 @@ import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants'; +import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; import type { CanvasV2State, StageAttrs } from 'features/controlLayers/store/types'; -import type Konva from 'konva'; -export const fitDocumentToStage = (stage: Konva.Stage, document: CanvasV2State['document']): StageAttrs => { - // Fit & center the document on the stage - const width = stage.width(); - const height = 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; - stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale }); - return { x, y, width, height, scale }; -}; +/** + * Gets a function to fit the document to the stage, resetting the stage scale to 100%. + * If the document is smaller than the stage, the stage scale is increased to fit the document. + * @param arg.manager The konva node manager + * @param arg.getDocument A function to get the current document state + * @param arg.setStageAttrs A function to set the stage attributes + * @returns A function to fit the document to the stage + */ +export const getFitDocumentToStage = + (arg: { + manager: KonvaNodeManager; + getDocument: () => CanvasV2State['document']; + setStageAttrs: (stageAttrs: StageAttrs) => void; + }) => + (): void => { + const { manager, getDocument, setStageAttrs } = arg; + const document = getDocument(); + // Fit & center the document on the stage + const width = manager.stage.width(); + const height = manager.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; + manager.stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale }); + setStageAttrs({ x, y, width, height, scale }); + };