From 766e8c4eb06e212c5a524e435ee329813b93c05a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 26 Jun 2024 17:28:13 +1000 Subject: [PATCH] feat(ui): consolidate konva API --- .../features/controlLayers/konva/events.ts | 9 +- .../controlLayers/konva/nodeManager.ts | 90 +- .../controlLayers/konva/renderers/arrange.ts | 31 - .../konva/renderers/background.ts | 95 +- .../konva/renderers/controlAdapters.ts | 2 +- .../konva/renderers/inpaintMask.ts | 2 +- .../controlLayers/konva/renderers/layers.ts | 2 +- .../controlLayers/konva/renderers/preview.ts | 1058 ++++++++--------- .../controlLayers/konva/renderers/regions.ts | 2 +- .../controlLayers/konva/renderers/renderer.ts | 27 +- .../controlLayers/konva/renderers/stage.ts | 52 - 11 files changed, 525 insertions(+), 845 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts delete mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 97e887067a..c0901e4f0d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -467,7 +467,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = stage.position(newPos); setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale }); manager.renderBackground(); - manager.renderDocumentOverlay(); + manager.renderDocumentSizeOverlay(); } } manager.renderToolPreview(); @@ -483,7 +483,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = scale: stage.scaleX(), }); manager.renderBackground(); - manager.renderDocumentOverlay(); + manager.renderDocumentSizeOverlay(); manager.renderToolPreview(); }); @@ -518,10 +518,9 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) = setTool('view'); setSpaceKey(true); } else if (e.key === 'r') { - manager.fitDocumentToStage(); - manager.renderToolPreview(); + manager.fitDocument(); manager.renderBackground(); - manager.renderDocumentOverlay(); + manager.renderDocumentSizeOverlay(); } manager.renderToolPreview(); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts index 56c26d2cb3..4f0b043bb4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/nodeManager.ts @@ -1,7 +1,6 @@ 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 { CanvasBackground } from 'features/controlLayers/konva/renderers/background'; +import { CanvasPreview } from 'features/controlLayers/konva/renderers/preview'; import { konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util'; import type { BrushLineAddedArg, @@ -20,14 +19,15 @@ 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'; -import { KonvaControlAdapter } from './renderers/controlAdapters'; -import { KonvaInpaintMask } from './renderers/inpaintMask'; -import { KonvaLayerAdapter } from './renderers/layers'; -import { KonvaRegion } from './renderers/regions'; +import { CanvasControlAdapter } from './renderers/controlAdapters'; +import { CanvasInpaintMask } from './renderers/inpaintMask'; +import { CanvasLayer } from './renderers/layers'; +import { CanvasRegion } from './renderers/regions'; export type StateApi = { getToolState: () => CanvasV2State['tool']; @@ -90,17 +90,27 @@ type Util = { getGenerationMode: () => GenerationMode; }; +const $nodeManager = atom(null); +export function getNodeManager() { + const nodeManager = $nodeManager.get(); + assert(nodeManager !== null, 'Node manager not initialized'); + return nodeManager; +} +export function setNodeManager(nodeManager: KonvaNodeManager) { + $nodeManager.set(nodeManager); +} + export class KonvaNodeManager { stage: Konva.Stage; container: HTMLDivElement; - controlAdapters: Map; - layers: Map; - regions: Map; - inpaintMask: KonvaInpaintMask | null; + controlAdapters: Map; + layers: Map; + regions: Map; + inpaintMask: CanvasInpaintMask | null; util: Util; stateApi: StateApi; - preview: KonvaPreview; - background: KonvaBackground; + preview: CanvasPreview; + background: CanvasBackground; constructor( stage: Konva.Stage, @@ -122,7 +132,8 @@ export class KonvaNodeManager { getCompositeLayerStageClone: this._getCompositeLayerStageClone.bind(this), getGenerationMode: this._getGenerationMode.bind(this), }; - this.preview = new KonvaPreview( + + this.preview = new CanvasPreview( this.stage, this.stateApi.getBbox, this.stateApi.onBboxTransformed, @@ -131,7 +142,11 @@ export class KonvaNodeManager { this.stateApi.getMetaKey, this.stateApi.getAltKey ); - this.background = new KonvaBackground(); + this.stage.add(this.preview.konvaLayer); + + this.background = new CanvasBackground(); + this.stage.add(this.background.konvaLayer); + this.layers = new Map(); this.regions = new Map(); this.controlAdapters = new Map(); @@ -152,7 +167,7 @@ export class KonvaNodeManager { for (const entity of entities) { let adapter = this.layers.get(entity.id); if (!adapter) { - adapter = new KonvaLayerAdapter(entity, this.stateApi.onPosChanged); + adapter = new CanvasLayer(entity, this.stateApi.onPosChanged); this.layers.set(adapter.id, adapter); this.stage.add(adapter.konvaLayer); } @@ -177,7 +192,7 @@ export class KonvaNodeManager { for (const entity of entities) { let adapter = this.regions.get(entity.id); if (!adapter) { - adapter = new KonvaRegion(entity, this.stateApi.onPosChanged); + adapter = new CanvasRegion(entity, this.stateApi.onPosChanged); this.regions.set(adapter.id, adapter); this.stage.add(adapter.konvaLayer); } @@ -188,7 +203,7 @@ export class KonvaNodeManager { renderInpaintMask() { const inpaintMaskState = this.stateApi.getInpaintMaskState(); if (!this.inpaintMask) { - this.inpaintMask = new KonvaInpaintMask(inpaintMaskState, this.stateApi.onPosChanged); + this.inpaintMask = new CanvasInpaintMask(inpaintMaskState, this.stateApi.onPosChanged); this.stage.add(this.inpaintMask.konvaLayer); } const toolState = this.stateApi.getToolState(); @@ -211,7 +226,7 @@ export class KonvaNodeManager { for (const entity of entities) { let adapter = this.controlAdapters.get(entity.id); if (!adapter) { - adapter = new KonvaControlAdapter(entity); + adapter = new CanvasControlAdapter(entity); this.controlAdapters.set(adapter.id, adapter); this.stage.add(adapter.konvaLayer); } @@ -239,18 +254,18 @@ export class KonvaNodeManager { this.preview.konvaLayer.zIndex(++zIndex); } - renderDocumentOverlay() { - this.preview.renderDocumentOverlay(this.stage, this.stateApi.getDocument()); + renderDocumentSizeOverlay() { + this.preview.documentSizeOverlay.render(this.stage, this.stateApi.getDocument()); } renderBbox() { - this.preview.renderBbox(this.stateApi.getBbox(), this.stateApi.getToolState()); + this.preview.bbox.render(this.stateApi.getBbox(), this.stateApi.getToolState()); } renderToolPreview() { - this.preview.renderToolPreview( + this.preview.tool.render( this.stage, - 1, + 1, // TODO(psyche): this should be renderable entity count this.stateApi.getToolState(), this.stateApi.getCurrentFill(), this.stateApi.getSelectedEntity(), @@ -261,22 +276,15 @@ export class KonvaNodeManager { ); } - 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 }); + renderBackground() { + this.background.renderBackground(this.stage); } - fitStageToContainer(): void { + fitDocument() { + this.preview.documentSizeOverlay.fitToStage(this.stage, this.stateApi.getDocument(), this.stateApi.setStageAttrs); + } + + fitStageToContainer() { this.stage.width(this.container.offsetWidth); this.stage.height(this.container.offsetHeight); this.stateApi.setStageAttrs({ @@ -287,11 +295,7 @@ export class KonvaNodeManager { scale: this.stage.scaleX(), }); this.renderBackground(); - this.renderDocumentOverlay(); - } - - renderBackground() { - this.background.renderBackground(this.stage); + this.renderDocumentSizeOverlay(); } _getMaskLayerClone(): Konva.Layer { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts deleted file mode 100644 index dea5aba2a3..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/arrange.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; - -/** - * Gets a function to arrange the entities in the konva stage. - * @param manager The konva node manager - * @returns An arrange entities function - */ -export const getArrangeEntities = (manager: KonvaNodeManager) => { - const { getLayersState, getControlAdaptersState, getRegionsState } = manager.stateApi; - - function arrangeEntities(): void { - const layers = getLayersState().entities; - const controlAdapters = getControlAdaptersState().entities; - const regions = getRegionsState().entities; - 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.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 3e1dd6de06..a4d7521463 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/background.ts @@ -1,6 +1,4 @@ import { getArbitraryBaseColor } from '@invoke-ai/ui-library'; -import { BACKGROUND_LAYER_ID } from 'features/controlLayers/konva/naming'; -import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; import Konva from 'konva'; const baseGridLineColor = getArbitraryBaseColor(27); @@ -30,98 +28,7 @@ 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 }); - -/** - * Gets a render function for the background layer. - * @param manager The konva node manager - * @returns A function to render the background grid - */ -export const getRenderBackground = (manager: KonvaNodeManager) => { - function renderBackground(): void { - const background = manager.background.layer; - background.zIndex(0); - const scale = manager.stage.scaleX(); - const gridSpacing = getGridSpacing(scale); - const x = manager.stage.x(); - const y = manager.stage.y(); - const width = manager.stage.width(); - const height = manager.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; - - background.destroyChildren(); - - for (let i = 0; i < xSteps; i++) { - _x = gridFullRect.x1 + i * gridSpacing; - background.add( - new Konva.Line({ - x: _x, - y: gridFullRect.y1, - points: [0, 0, 0, ySize], - stroke: _x % 64 ? fineGridLineColor : baseGridLineColor, - strokeWidth, - listening: false, - }) - ); - } - for (let i = 0; i < ySteps; i++) { - _y = gridFullRect.y1 + i * gridSpacing; - background.add( - new Konva.Line({ - x: gridFullRect.x1, - y: _y, - points: [0, 0, xSize, 0], - stroke: _y % 64 ? fineGridLineColor : baseGridLineColor, - strokeWidth, - listening: false, - }) - ); - } - } - - return renderBackground; -}; - -export class KonvaBackground { +export class CanvasBackground { konvaLayer: Konva.Layer; constructor() { 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 81c392cb9e..a63ff80269 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/controlAdapters.ts @@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import { KonvaImage } from './objects'; -export class KonvaControlAdapter { +export class CanvasControlAdapter { id: string; konvaLayer: Konva.Layer; konvaObjectGroup: Konva.Group; 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 780121b20a..8663989804 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/inpaintMask.ts @@ -9,7 +9,7 @@ import Konva from 'konva'; import { assert } from 'tsafe'; import { v4 as uuidv4 } from 'uuid'; -export class KonvaInpaintMask { +export class CanvasInpaintMask { id: string; konvaLayer: Konva.Layer; konvaObjectGroup: Konva.Group; 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 d034078d64..ad138a66ab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/layers.ts @@ -7,7 +7,7 @@ import Konva from 'konva'; import { assert } from 'tsafe'; import { v4 as uuidv4 } from 'uuid'; -export class KonvaLayerAdapter { +export class CanvasLayer { id: string; konvaLayer: Konva.Layer; konvaObjectGroup: Konva.Group; 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 5605b4f40a..798052c7a1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/preview.ts @@ -5,491 +5,48 @@ import { BRUSH_BORDER_INNER_COLOR, BRUSH_BORDER_OUTER_COLOR, BRUSH_ERASER_BORDER_WIDTH, + DOCUMENT_FIT_PADDING_PX, } from 'features/controlLayers/konva/constants'; import { - PREVIEW_BRUSH_BORDER_INNER_ID, - PREVIEW_BRUSH_BORDER_OUTER_ID, - PREVIEW_BRUSH_FILL_ID, - PREVIEW_BRUSH_GROUP_ID, PREVIEW_GENERATION_BBOX_DUMMY_RECT, PREVIEW_GENERATION_BBOX_GROUP, PREVIEW_GENERATION_BBOX_TRANSFORMER, - PREVIEW_LAYER_ID, PREVIEW_RECT_ID, - PREVIEW_TOOL_GROUP_ID, } from 'features/controlLayers/konva/naming'; -import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; -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 { KonvaImage } from 'features/controlLayers/konva/renderers/objects'; +import type { CanvasEntity, CanvasV2State, Position, RgbaColor, StageAttrs } 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. - * @returns The konva preview layer - */ -export const createPreviewLayer = (): Konva.Layer => new Konva.Layer({ id: PREVIEW_LAYER_ID, listening: true }); +export class CanvasDocumentSizeOverlay { + group: Konva.Group; + outerRect: Konva.Rect; + innerRect: Konva.Rect; + padding: number; -/** - * 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, - getShiftKey: () => boolean, - getCtrlKey: () => boolean, - getMetaKey: () => boolean, - getAltKey: () => boolean -): { group: Konva.Group; rect: Konva.Rect; transformer: Konva.Transformer } => { - // Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when - // transforming the bbox. - const bbox = getBbox(); - const $aspectRatioBuffer = atom(bbox.width / bbox.height); - - // Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully - // transparent rect for this purpose. - const group = new Konva.Group({ id: PREVIEW_GENERATION_BBOX_GROUP, listening: false }); - const rect = new Konva.Rect({ - id: PREVIEW_GENERATION_BBOX_DUMMY_RECT, - listening: false, - strokeEnabled: false, - draggable: true, - ...getBbox(), - }); - rect.on('dragmove', () => { - const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64; - const oldBbox = getBbox(); - const newBbox: IRect = { - ...oldBbox, - x: roundToMultiple(rect.x(), gridSize), - y: roundToMultiple(rect.y(), gridSize), - }; - rect.setAttrs(newBbox); - if (oldBbox.x !== newBbox.x || oldBbox.y !== newBbox.y) { - onBboxTransformed(newBbox); - } - }); - const transformer = new Konva.Transformer({ - id: PREVIEW_GENERATION_BBOX_TRANSFORMER, - borderDash: [5, 5], - borderStroke: 'rgba(212,216,234,1)', - borderEnabled: true, - rotateEnabled: false, - keepRatio: false, - ignoreStroke: true, - listening: false, - flipEnabled: false, - anchorFill: 'rgba(212,216,234,1)', - anchorStroke: 'rgb(42,42,42)', - anchorSize: 12, - anchorCornerRadius: 3, - shiftBehavior: 'none', // we will implement our own shift behavior - centeredScaling: false, - anchorStyleFunc: (anchor) => { - // Make the x/y resize anchors little bars - if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) { - anchor.height(8); - anchor.offsetY(4); - anchor.width(30); - anchor.offsetX(15); - } - if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) { - anchor.height(30); - anchor.offsetY(15); - anchor.width(8); - anchor.offsetX(4); - } - }, - anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => { - // This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed - // to konva's internal coordinate system. - - // We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid. - const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64; - // Because we are working in absolute coordinates, we need to scale the grid size by the stage scale. - const scaledGridSize = gridSize * stage.scaleX(); - // To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position. - const stageAbsPos = stage.getAbsolutePosition(); - // The offset is the remainder of the stage's absolute position divided by the scaled grid size. - const offsetX = stageAbsPos.x % scaledGridSize; - const offsetY = stageAbsPos.y % scaledGridSize; - // Finally, calculate the position by rounding to the grid and adding the offset. - return { - x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX, - y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY, - }; - }, - }); - - transformer.on('transform', () => { - // In the transform callback, we calculate the bbox's new dims and pos and update the konva object. - - // Some special handling is needed depending on the anchor being dragged. - const anchor = transformer.getActiveAnchor(); - if (!anchor) { - // Pretty sure we should always have an anchor here? - return; - } - - const alt = getAltKey(); - const ctrl = getCtrlKey(); - const meta = getMetaKey(); - const shift = getShiftKey(); - - // Grid size depends on the modifier keys - let gridSize = ctrl || meta ? 8 : 64; - - // Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the - // new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if - // we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes. - // Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid. - if (getAltKey()) { - gridSize = gridSize * 2; - } - - // The coords should be correct per the anchorDragBoundFunc. - let x = rect.x(); - let y = rect.y(); - - // Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height - // *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap - // them to the grid. - let width = roundToMultipleMin(rect.width() * rect.scaleX(), gridSize); - let height = roundToMultipleMin(rect.height() * rect.scaleY(), gridSize); - - // If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this - // if alt/opt is held - this requires math too big for my brain. - if (shift && CORNER_ANCHORS.includes(anchor) && !alt) { - // Fit the bbox to the last aspect ratio - let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get()); - let fittedHeight = fittedWidth / $aspectRatioBuffer.get(); - fittedWidth = roundToMultipleMin(fittedWidth, gridSize); - fittedHeight = roundToMultipleMin(fittedHeight, gridSize); - - // We need to adjust the x and y coords to have the resize occur from the right origin. - if (anchor === 'top-left') { - // The transform origin is the bottom-right anchor. Both x and y need to be updated. - x = x - (fittedWidth - width); - y = y - (fittedHeight - height); - } - if (anchor === 'top-right') { - // The transform origin is the bottom-left anchor. Only y needs to be updated. - y = y - (fittedHeight - height); - } - if (anchor === 'bottom-left') { - // The transform origin is the top-right anchor. Only x needs to be updated. - x = x - (fittedWidth - width); - } - // Update the width and height to the fitted dims. - width = fittedWidth; - height = fittedHeight; - } - - const bbox = { - x: Math.round(x), - y: Math.round(y), - width, - height, - }; - - // Update the bboxRect's attrs directly with the new transform, and reset its scale to 1. - // TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly. - // Gotta be a way to avoid setting it twice... - rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 }); - - // Update the bbox in internal state. - onBboxTransformed(bbox); - - // Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start - // a transform, get the right aspect ratio, then hold shift to lock it in. - if (!shift) { - $aspectRatioBuffer.set(bbox.width / bbox.height); - } - }); - - transformer.on('transformend', () => { - // Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held, - // we have the correct aspect ratio to start from. - $aspectRatioBuffer.set(rect.width() / rect.height()); - }); - - // The transformer will always be transforming the dummy rect - transformer.nodes([rect]); - group.add(rect); - group.add(transformer); - return { group, rect, transformer }; -}; - -const ALL_ANCHORS: string[] = [ - 'top-left', - 'top-center', - 'top-right', - 'middle-right', - 'middle-left', - 'bottom-left', - 'bottom-center', - 'bottom-right', -]; -const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; -const NO_ANCHORS: string[] = []; - -/** - * Gets the bbox render function. - * @param manager The konva node manager - * @returns The bbox render function - */ -export const getRenderBbox = (manager: KonvaNodeManager) => { - const { getBbox, getToolState } = manager.stateApi; - - return (): 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', + constructor(padding?: number) { + this.padding = padding ?? DOCUMENT_FIT_PADDING_PX; + this.group = new Konva.Group({ id: 'document_overlay_group', listening: false }); + this.outerRect = new Konva.Rect({ + id: 'document_overlay_outer_rect', + listening: false, + fill: getArbitraryBaseColor(10), + opacity: 0.7, }); - manager.preview.bbox.transformer.setAttrs({ - listening: toolState.selected === 'bbox', - enabledAnchors: toolState.selected === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, + this.innerRect = new Konva.Rect({ + id: 'document_overlay_inner_rect', + listening: false, + fill: 'white', + globalCompositeOperation: 'destination-out', }); - }; -}; + this.group.add(this.outerRect); + this.group.add(this.innerRect); + } -/** - * 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 - const brushGroup = new Konva.Group({ id: PREVIEW_BRUSH_GROUP_ID }); - const brushFill = new Konva.Circle({ - id: PREVIEW_BRUSH_FILL_ID, - listening: false, - strokeEnabled: false, - }); - brushGroup.add(brushFill); - const brushBorderInner = new Konva.Circle({ - id: PREVIEW_BRUSH_BORDER_INNER_ID, - listening: false, - stroke: BRUSH_BORDER_INNER_COLOR, - strokeWidth: BRUSH_ERASER_BORDER_WIDTH, - strokeEnabled: true, - }); - brushGroup.add(brushBorderInner); - const brushBorderOuter = new Konva.Circle({ - id: PREVIEW_BRUSH_BORDER_OUTER_ID, - listening: false, - stroke: BRUSH_BORDER_OUTER_COLOR, - strokeWidth: BRUSH_ERASER_BORDER_WIDTH, - strokeEnabled: true, - }); - brushGroup.add(brushBorderOuter); - - // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position - const rect = new Konva.Rect({ - id: PREVIEW_RECT_ID, - listening: false, - strokeEnabled: false, - }); - - group.add(rect); - group.add(brushGroup); - return { - group, - brush: { - group: brushGroup, - fill: brushFill, - innerBorder: brushBorderInner, - outerBorder: brushBorderOuter, - }, - rect: { - rect, - }, - }; -}; - -/** - * Gets the tool preview (brush, eraser, rect) render function. - * @param manager The konva node manager - * @returns The tool preview render function - */ -export const getRenderToolPreview = (manager: KonvaNodeManager) => { - const { - getToolState, - getCurrentFill, - getSelectedEntity, - getLastCursorPos, - getLastMouseDownPos, - getIsDrawing, - getIsMouseDown, - } = manager.stateApi; - - return (): void => { - 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'; - - // 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'; - } - - 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); - } - - 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); - manager.preview.tool.brush.outerBorder.setAttrs({ - strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, - radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, - }); -}; - -/** - * 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({ - id: 'document_overlay_outer_rect', - listening: false, - fill: getArbitraryBaseColor(10), - opacity: 0.7, - }); - const innerRect = new Konva.Rect({ - id: 'document_overlay_inner_rect', - listening: false, - fill: 'white', - globalCompositeOperation: 'destination-out', - }); - group.add(outerRect); - group.add(innerRect); - return { group, innerRect, outerRect }; -}; - -/** - * Gets the document overlay render function. - * @param manager The konva node manager - * @returns The document overlay render function - */ -export const getRenderDocumentOverlay = (manager: KonvaNodeManager) => { - const { getDocument } = manager.stateApi; - - function renderDocumentOverlay(): void { - const document = getDocument(); - const stage = manager.stage; - - manager.preview.documentOverlay.group.zIndex(0); + render(stage: Konva.Stage, document: CanvasV2State['document']) { + this.group.zIndex(0); const x = stage.x(); const y = stage.y(); @@ -497,14 +54,14 @@ export const getRenderDocumentOverlay = (manager: KonvaNodeManager) => { const height = stage.height(); const scale = stage.scaleX(); - manager.preview.documentOverlay.outerRect.setAttrs({ + this.outerRect.setAttrs({ offsetX: x / scale, offsetY: y / scale, width: width / scale, height: height / scale, }); - manager.preview.documentOverlay.innerRect.setAttrs({ + this.innerRect.setAttrs({ x: 0, y: 0, width: document.width, @@ -512,119 +69,172 @@ export const getRenderDocumentOverlay = (manager: KonvaNodeManager) => { }); } - return renderDocumentOverlay; -}; + fitToStage(stage: Konva.Stage, document: CanvasV2State['document'], setStageAttrs: (attrs: StageAttrs) => void) { + // Fit & center the document on the stage + const width = stage.width(); + const height = stage.height(); + const docWidthWithBuffer = document.width + this.padding * 2; + const docHeightWithBuffer = document.height + this.padding * 2; + const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1); + const x = (width - docWidthWithBuffer * scale) / 2 + this.padding * scale; + const y = (height - docHeightWithBuffer * scale) / 2 + this.padding * scale; + stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale }); + setStageAttrs({ x, y, width, height, scale }); + } +} -export const createStagingArea = (): KonvaNodeManager['preview']['stagingArea'] => { - const group = new Konva.Group({ id: 'staging_area_group', listening: false }); - return { group, image: null }; -}; +export class CanvasStagingArea { + group: Konva.Group; + image: KonvaImage | 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; + constructor() { + this.group = new Konva.Group({ listening: false }); + this.image = null; } - 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), + async render(stagingArea: CanvasV2State['stagingArea']) { + if (!stagingArea || stagingArea.selectedImageIndex === null) { + if (this.image) { + this.image.destroy(); + this.image = null; + } + return; + } + + if (stagingArea.selectedImageIndex) { + const imageDTO = stagingArea.images[stagingArea.selectedImageIndex]; + assert(imageDTO, 'Image must exist'); + if (this.image) { + if (this.image.imageName !== imageDTO.image_name) { + await this.image.updateImageSource(imageDTO.image_name); + } + } else { + const { image_name, width, height } = imageDTO; + this.image = new KonvaImage({ + imageObject: { + id: 'staging-area-image', + type: 'image', + x: 0, + y: 0, + width, + height, + filters: [], + image: { + name: image_name, + width, + height, + }, + }, }); } - } else { - manager.preview.stagingArea.image = await createImageObjectGroup({ - obj: imageDTOToImageObject(imageDTO), - name: imageDTO.image_name, - }); } } -}; +} -export class KonvaPreview { - konvaLayer: Konva.Layer; - bbox: { +export class CanvasTool { + group: Konva.Group; + brush: { group: Konva.Group; - rect: Konva.Rect; - transformer: Konva.Transformer; + fillCircle: Konva.Circle; + innerBorderCircle: Konva.Circle; + outerBorderCircle: Konva.Circle; }; - tool: { + eraser: { group: Konva.Group; - brush: { - group: Konva.Group; - fill: Konva.Circle; - innerBorder: Konva.Circle; - outerBorder: Konva.Circle; - }; - rect: { - rect: Konva.Rect; - }; + fillCircle: Konva.Circle; + innerBorderCircle: Konva.Circle; + outerBorderCircle: Konva.Circle; }; - documentOverlay: { + rect: { group: Konva.Group; - innerRect: Konva.Rect; - outerRect: Konva.Rect; - }; - stagingArea: { - group: Konva.Group; - // image: KonvaImage | null; + fillRect: Konva.Rect; }; - 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(); + constructor() { + this.group = new Konva.Group(); + + // Create the brush preview group & circles + this.brush = { + group: new Konva.Group(), + fillCircle: new Konva.Circle({ + listening: false, + strokeEnabled: false, + }), + innerBorderCircle: new Konva.Circle({ + listening: false, + stroke: BRUSH_BORDER_INNER_COLOR, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, + strokeEnabled: true, + }), + outerBorderCircle: new Konva.Circle({ + listening: false, + stroke: BRUSH_BORDER_OUTER_COLOR, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, + strokeEnabled: true, + }), + }; + this.brush.group.add(this.brush.fillCircle); + this.brush.group.add(this.brush.innerBorderCircle); + this.brush.group.add(this.brush.outerBorderCircle); + this.group.add(this.brush.group); + + this.eraser = { + group: new Konva.Group(), + fillCircle: new Konva.Circle({ + listening: false, + strokeEnabled: false, + fill: 'white', + globalCompositeOperation: 'destination-out', + }), + innerBorderCircle: new Konva.Circle({ + listening: false, + stroke: BRUSH_BORDER_INNER_COLOR, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, + strokeEnabled: true, + }), + outerBorderCircle: new Konva.Circle({ + listening: false, + stroke: BRUSH_BORDER_OUTER_COLOR, + strokeWidth: BRUSH_ERASER_BORDER_WIDTH, + strokeEnabled: true, + }), + }; + this.eraser.group.add(this.eraser.fillCircle); + this.eraser.group.add(this.eraser.innerBorderCircle); + this.eraser.group.add(this.eraser.outerBorderCircle); + this.group.add(this.eraser.group); + + // Create the rect preview - this is a rectangle drawn from the last mouse down position to the current cursor position + this.rect = { + group: new Konva.Group(), + fillRect: new Konva.Rect({ + id: PREVIEW_RECT_ID, + listening: false, + strokeEnabled: false, + }), + }; + this.group.add(this.rect.group); } - 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']) { + scaleTool(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({ + + const brushRadius = toolState.brush.width / 2; + this.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale); + this.brush.outerBorderCircle.setAttrs({ strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, - radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, + radius: brushRadius + BRUSH_ERASER_BORDER_WIDTH / scale, + }); + + const eraserRadius = toolState.eraser.width / 2; + this.eraser.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale); + this.eraser.outerBorderCircle.setAttrs({ + strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale, + radius: eraserRadius + BRUSH_ERASER_BORDER_WIDTH / scale, }); } - renderToolPreview( + render( stage: Konva.Stage, renderedEntityCount: number, toolState: CanvasV2State['tool'], @@ -668,42 +278,65 @@ export class KonvaPreview { 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); + this.group.visible(false); } else { - this.tool.group.visible(true); + this.group.visible(true); // No need to render the brush preview if the cursor position or color is missing - if (cursorPos && (tool === 'brush' || tool === 'eraser')) { + if (cursorPos && tool === 'brush') { const scale = stage.scaleX(); // Update the fill circle - const radius = (tool === 'brush' ? toolState.brush.width : toolState.eraser.width) / 2; - this.tool.brush.fill.setAttrs({ + const radius = toolState.brush.width / 2; + this.brush.fillCircle.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 }); + this.brush.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius }); // Update the outer border of the brush preview - this.tool.brush.outerBorder.setAttrs({ + this.brush.outerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, }); - this.scaleToolPreview(stage, toolState); + this.scaleTool(stage, toolState); - this.tool.brush.group.visible(true); - } else { - this.tool.brush.group.visible(false); - } + this.brush.group.visible(true); + this.eraser.group.visible(false); + this.rect.group.visible(false); + } else if (cursorPos && tool === 'eraser') { + const scale = stage.scaleX(); + // Update the fill circle + const radius = toolState.eraser.width / 2; + this.eraser.fillCircle.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius, + fill: 'white', + }); - if (cursorPos && lastMouseDownPos && tool === 'rect') { - this.tool.rect.rect.setAttrs({ + // Update the inner border of the eraser preview + this.eraser.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius }); + + // Update the outer border of the eraser preview + this.eraser.outerBorderCircle.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale, + }); + + this.scaleTool(stage, toolState); + + this.brush.group.visible(false); + this.eraser.group.visible(true); + this.rect.group.visible(false); + } else if (cursorPos && lastMouseDownPos && tool === 'rect') { + this.rect.fillRect.setAttrs({ x: Math.min(cursorPos.x, lastMouseDownPos.x), y: Math.min(cursorPos.y, lastMouseDownPos.y), width: Math.abs(cursorPos.x - lastMouseDownPos.x), @@ -711,33 +344,270 @@ export class KonvaPreview { fill: rgbaColorToString(currentFill), visible: true, }); + this.brush.group.visible(false); + this.eraser.group.visible(false); + this.rect.group.visible(true); } else { - this.tool.rect.rect.visible(false); + this.brush.group.visible(false); + this.eraser.group.visible(false); + this.rect.group.visible(false); } } } +} - renderDocumentOverlay(stage: Konva.Stage, document: CanvasV2State['document']) { - this.documentOverlay.group.zIndex(0); +export class CanvasBbox { + group: Konva.Group; + rect: Konva.Rect; + transformer: Konva.Transformer; - const x = stage.x(); - const y = stage.y(); - const width = stage.width(); - const height = stage.height(); - const scale = stage.scaleX(); + ALL_ANCHORS: string[] = [ + 'top-left', + 'top-center', + 'top-right', + 'middle-right', + 'middle-left', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ]; + CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + NO_ANCHORS: string[] = []; - this.documentOverlay.outerRect.setAttrs({ - offsetX: x / scale, - offsetY: y / scale, - width: width / scale, - height: height / scale, + constructor( + stage: Konva.Stage, + getBbox: () => IRect, + onBboxTransformed: (bbox: IRect) => void, + getShiftKey: () => boolean, + getCtrlKey: () => boolean, + getMetaKey: () => boolean, + getAltKey: () => boolean + ) { + // Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when + // transforming the bbox. + const bbox = getBbox(); + const $aspectRatioBuffer = atom(bbox.width / bbox.height); + + // Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully + // transparent rect for this purpose. + this.group = new Konva.Group({ id: PREVIEW_GENERATION_BBOX_GROUP, listening: false }); + this.rect = new Konva.Rect({ + id: PREVIEW_GENERATION_BBOX_DUMMY_RECT, + listening: false, + strokeEnabled: false, + draggable: true, + ...getBbox(), + }); + this.rect.on('dragmove', () => { + const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64; + const oldBbox = getBbox(); + const newBbox: IRect = { + ...oldBbox, + x: roundToMultiple(this.rect.x(), gridSize), + y: roundToMultiple(this.rect.y(), gridSize), + }; + this.rect.setAttrs(newBbox); + if (oldBbox.x !== newBbox.x || oldBbox.y !== newBbox.y) { + onBboxTransformed(newBbox); + } }); - this.documentOverlay.innerRect.setAttrs({ - x: 0, - y: 0, - width: document.width, - height: document.height, + this.transformer = new Konva.Transformer({ + id: PREVIEW_GENERATION_BBOX_TRANSFORMER, + borderDash: [5, 5], + borderStroke: 'rgba(212,216,234,1)', + borderEnabled: true, + rotateEnabled: false, + keepRatio: false, + ignoreStroke: true, + listening: false, + flipEnabled: false, + anchorFill: 'rgba(212,216,234,1)', + anchorStroke: 'rgb(42,42,42)', + anchorSize: 12, + anchorCornerRadius: 3, + shiftBehavior: 'none', // we will implement our own shift behavior + centeredScaling: false, + anchorStyleFunc: (anchor) => { + // Make the x/y resize anchors little bars + if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) { + anchor.height(8); + anchor.offsetY(4); + anchor.width(30); + anchor.offsetX(15); + } + if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) { + anchor.height(30); + anchor.offsetY(15); + anchor.width(8); + anchor.offsetX(4); + } + }, + anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => { + // This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed + // to konva's internal coordinate system. + + // We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid. + const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64; + // Because we are working in absolute coordinates, we need to scale the grid size by the stage scale. + const scaledGridSize = gridSize * stage.scaleX(); + // To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position. + const stageAbsPos = stage.getAbsolutePosition(); + // The offset is the remainder of the stage's absolute position divided by the scaled grid size. + const offsetX = stageAbsPos.x % scaledGridSize; + const offsetY = stageAbsPos.y % scaledGridSize; + // Finally, calculate the position by rounding to the grid and adding the offset. + return { + x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX, + y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY, + }; + }, + }); + + this.transformer.on('transform', () => { + // In the transform callback, we calculate the bbox's new dims and pos and update the konva object. + + // Some special handling is needed depending on the anchor being dragged. + const anchor = this.transformer.getActiveAnchor(); + if (!anchor) { + // Pretty sure we should always have an anchor here? + return; + } + + const alt = getAltKey(); + const ctrl = getCtrlKey(); + const meta = getMetaKey(); + const shift = getShiftKey(); + + // Grid size depends on the modifier keys + let gridSize = ctrl || meta ? 8 : 64; + + // Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the + // new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if + // we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes. + // Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid. + if (getAltKey()) { + gridSize = gridSize * 2; + } + + // The coords should be correct per the anchorDragBoundFunc. + let x = this.rect.x(); + let y = this.rect.y(); + + // Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height + // *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap + // them to the grid. + let width = roundToMultipleMin(this.rect.width() * this.rect.scaleX(), gridSize); + let height = roundToMultipleMin(this.rect.height() * this.rect.scaleY(), gridSize); + + // If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this + // if alt/opt is held - this requires math too big for my brain. + if (shift && this.CORNER_ANCHORS.includes(anchor) && !alt) { + // Fit the bbox to the last aspect ratio + let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get()); + let fittedHeight = fittedWidth / $aspectRatioBuffer.get(); + fittedWidth = roundToMultipleMin(fittedWidth, gridSize); + fittedHeight = roundToMultipleMin(fittedHeight, gridSize); + + // We need to adjust the x and y coords to have the resize occur from the right origin. + if (anchor === 'top-left') { + // The transform origin is the bottom-right anchor. Both x and y need to be updated. + x = x - (fittedWidth - width); + y = y - (fittedHeight - height); + } + if (anchor === 'top-right') { + // The transform origin is the bottom-left anchor. Only y needs to be updated. + y = y - (fittedHeight - height); + } + if (anchor === 'bottom-left') { + // The transform origin is the top-right anchor. Only x needs to be updated. + x = x - (fittedWidth - width); + } + // Update the width and height to the fitted dims. + width = fittedWidth; + height = fittedHeight; + } + + const bbox = { + x: Math.round(x), + y: Math.round(y), + width, + height, + }; + + // Update the bboxRect's attrs directly with the new transform, and reset its scale to 1. + // TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly. + // Gotta be a way to avoid setting it twice... + this.rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 }); + + // Update the bbox in internal state. + onBboxTransformed(bbox); + + // Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start + // a transform, get the right aspect ratio, then hold shift to lock it in. + if (!shift) { + $aspectRatioBuffer.set(bbox.width / bbox.height); + } + }); + + this.transformer.on('transformend', () => { + // Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held, + // we have the correct aspect ratio to start from. + $aspectRatioBuffer.set(this.rect.width() / this.rect.height()); + }); + + // The transformer will always be transforming the dummy rect + this.transformer.nodes([this.rect]); + this.group.add(this.rect); + this.group.add(this.transformer); + } + + render(bbox: CanvasV2State['bbox'], toolState: CanvasV2State['tool']) { + this.group.listening(toolState.selected === 'bbox'); + this.rect.setAttrs({ + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, + scaleX: 1, + scaleY: 1, + listening: toolState.selected === 'bbox', + }); + this.transformer.setAttrs({ + listening: toolState.selected === 'bbox', + enabledAnchors: toolState.selected === 'bbox' ? this.ALL_ANCHORS : this.NO_ANCHORS, }); } } + +export class CanvasPreview { + konvaLayer: Konva.Layer; + tool: CanvasTool; + bbox: CanvasBbox; + documentSizeOverlay: CanvasDocumentSizeOverlay; + stagingArea: CanvasStagingArea; + + constructor( + stage: Konva.Stage, + getBbox: () => IRect, + onBboxTransformed: (bbox: IRect) => void, + getShiftKey: () => boolean, + getCtrlKey: () => boolean, + getMetaKey: () => boolean, + getAltKey: () => boolean + ) { + this.konvaLayer = new Konva.Layer({ listening: true }); + + this.bbox = new CanvasBbox(stage, getBbox, onBboxTransformed, getShiftKey, getCtrlKey, getMetaKey, getAltKey); + this.konvaLayer.add(this.bbox.group); + + this.tool = new CanvasTool(); + this.konvaLayer.add(this.tool.group); + + this.documentSizeOverlay = new CanvasDocumentSizeOverlay(); + this.konvaLayer.add(this.documentSizeOverlay.group); + + this.stagingArea = new CanvasStagingArea(); + this.konvaLayer.add(this.stagingArea.group); + } +} 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 a7263df9f4..c715ae3781 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/regions.ts @@ -9,7 +9,7 @@ import Konva from 'konva'; import { assert } from 'tsafe'; import { v4 as uuidv4 } from 'uuid'; -export class KonvaRegion { +export class CanvasRegion { id: string; konvaLayer: Konva.Layer; konvaObjectGroup: Konva.Group; 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 dad2e4a51c..01d3e93d99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/renderer.ts @@ -5,9 +5,7 @@ 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 { KonvaBackground } from 'features/controlLayers/konva/renderers/background'; import { updateBboxes } from 'features/controlLayers/konva/renderers/bbox'; -import { KonvaPreview } from 'features/controlLayers/konva/renderers/preview'; import { $stageAttrs, bboxChanged, @@ -74,7 +72,7 @@ export const initializeRenderer = ( */ const logIfDebugging = (message: string) => { if ($isDebugging.get()) { - _log.trace(message); + _log.debug(message); } }; @@ -338,22 +336,6 @@ export const initializeRenderer = ( 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. @@ -408,7 +390,7 @@ export const initializeRenderer = ( if (isFirstRender || canvasV2.document !== prevCanvasV2.document) { logIfDebugging('Rendering document bounds overlay'); - manager.renderDocumentOverlay(); + manager.renderDocumentSizeOverlay(); } if (isFirstRender || canvasV2.bbox !== prevCanvasV2.bbox || canvasV2.tool.selected !== prevCanvasV2.tool.selected) { @@ -447,7 +429,7 @@ 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.fitStageToContainer); + const resizeObserver = new ResizeObserver(manager.fitStageToContainer.bind(manager)); resizeObserver.observe(container); manager.fitStageToContainer(); @@ -455,7 +437,8 @@ export const initializeRenderer = ( logIfDebugging('First render of konva stage'); // On first render, the document should be fit to the stage. - manager.fitDocumentToStage(); + manager.renderDocumentSizeOverlay(); + manager.fitDocument(); manager.renderToolPreview(); renderCanvas(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts b/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts deleted file mode 100644 index 70b05cf104..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/renderers/stage.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants'; -import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager'; - -/** - * 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 manager The konva node manager - * @returns A function to fit the document to the stage - */ -export const getFitDocumentToStage = (manager: KonvaNodeManager) => { - function fitDocumentToStage(): void { - const { getDocument, setStageAttrs } = manager.stateApi; - 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 }); - } - - return fitDocumentToStage; -}; - -/** - * Gets a function to fit the stage to its container element. Called during resize events. - * @param manager The konva node manager - * @returns A function to fit the stage to its container - */ -export const getFitStageToContainer = (manager: KonvaNodeManager) => { - const { stage, container } = manager; - const { setStageAttrs } = manager.stateApi; - function fitStageToContainer(): void { - stage.width(container.offsetWidth); - stage.height(container.offsetHeight); - setStageAttrs({ - x: stage.x(), - y: stage.y(), - width: stage.width(), - height: stage.height(), - scale: stage.scaleX(), - }); - manager.konvaApi.renderBackground(); - manager.renderDocumentOverlay(); - } - - return fitStageToContainer; -};